mover-os 4.7.8 → 4.7.9
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 +34 -24
- package/install.js +2197 -200
- package/package.json +1 -1
- package/src/dashboard/build.js +41 -3
- package/src/dashboard/lib/active-context-parser.js +16 -4
- package/src/dashboard/lib/agent-session.js +70 -49
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/daily-note-resolver.js +20 -3
- package/src/dashboard/lib/date-utils.js +9 -1
- package/src/dashboard/lib/distribution-parser.js +69 -10
- package/src/dashboard/lib/drift-history.js +6 -1
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +4 -1
- package/src/dashboard/lib/engine-writer.js +157 -11
- package/src/dashboard/lib/goal-forecast.js +154 -31
- package/src/dashboard/lib/library-indexer-v2.js +14 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +0 -0
- package/src/dashboard/lib/memory-index.js +61 -12
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/regenerate-manifest.js +0 -0
- package/src/dashboard/lib/run-registry.js +75 -15
- package/src/dashboard/lib/state-core/backfill.js +298 -0
- package/src/dashboard/lib/state-core/dryrun.js +188 -0
- package/src/dashboard/lib/state-core/event-log.js +615 -0
- package/src/dashboard/lib/state-core/events.js +265 -0
- package/src/dashboard/lib/state-core/projections.js +376 -0
- package/src/dashboard/lib/state-core/start-close.js +162 -0
- package/src/dashboard/lib/state-core/trial.js +96 -0
- package/src/dashboard/lib/strategy-parser.js +3 -0
- package/src/dashboard/lib/suggested-now.js +2 -2
- package/src/dashboard/lib/transcript-parser.js +48 -8
- package/src/dashboard/server.js +422 -44
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
- package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/index.html +2 -2
- package/src/dashboard/ui/dist/sw.js +1 -1
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
// library-search.js — ONE truthful search index for the Library route.
|
|
7
|
+
//
|
|
8
|
+
// Sol full product review (2026-07-11, Library 5.0/10): the "EVERYTHING"
|
|
9
|
+
// search box indexed only the libraryV2 subset while the other shelves were
|
|
10
|
+
// counts with no rows behind them. This module makes the coverage equal the
|
|
11
|
+
// advertised shelves: every shelf the bento names is either content-searchable
|
|
12
|
+
// here or explicitly reported as notIndexable with the reason (CLI commands
|
|
13
|
+
// are usage counts, not files).
|
|
14
|
+
//
|
|
15
|
+
// Contract:
|
|
16
|
+
// - Read-only. Never writes. Never leaves the declared shelf roots.
|
|
17
|
+
// - Bounded: per-file size cap, per-shelf file cap, result cap, passage cap.
|
|
18
|
+
// - Results carry root + vault-relative (or root-relative) path + a 1-based
|
|
19
|
+
// line anchor + the matched passage, so the UI can open the real file AT
|
|
20
|
+
// the passage in the existing viewer.
|
|
21
|
+
// - Honest coverage: shelves whose directory does not exist are reported in
|
|
22
|
+
// coverage.missing; shelves with nothing to read are in
|
|
23
|
+
// coverage.notIndexable. Nothing is silently skipped.
|
|
24
|
+
// - Non-text files (PDFs, images) match by NAME only and say so
|
|
25
|
+
// (nameOnly: true, line/passage null) — never implied content coverage.
|
|
26
|
+
|
|
27
|
+
const MAX_FILE_BYTES = 256 * 1024; // matches /api/file/read cap
|
|
28
|
+
const MAX_FILES_PER_SHELF = 2000;
|
|
29
|
+
const MAX_DEPTH = 6;
|
|
30
|
+
const MAX_LIMIT = 100;
|
|
31
|
+
const DEFAULT_LIMIT = 40;
|
|
32
|
+
const PASSAGE_CAP = 240;
|
|
33
|
+
const EXCLUDED_DIRS = new Set([
|
|
34
|
+
"node_modules", "dist", ".git", ".obsidian", ".trash", "coverage",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Extensions whose CONTENT we read as text. Everything else is name-only.
|
|
38
|
+
const TEXT_EXTS = new Set([".md", ".txt", ".sh", ".js", ".mjs", ".ps1", ".canvas", ".json"]);
|
|
39
|
+
|
|
40
|
+
// ── shelf definitions — ids are stable API; labels mirror the bento ─────────
|
|
41
|
+
// dirs(ctx) returns the roots to walk for that shelf, each tagged with the
|
|
42
|
+
// serving root id the file-read endpoint understands (vault|skills|workflows|hooks).
|
|
43
|
+
const SHELVES = [
|
|
44
|
+
{
|
|
45
|
+
id: "engine", label: "Engine files",
|
|
46
|
+
dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "02_Areas", "Engine"), depth: 1 }],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "projects", label: "Projects",
|
|
50
|
+
dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "01_Projects"), depth: MAX_DEPTH }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "library", label: "Library notes",
|
|
54
|
+
dirs: (ctx) => [
|
|
55
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "MOCs"), depth: 3 },
|
|
56
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Principles"), depth: 3 },
|
|
57
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "SOPs"), depth: 3 },
|
|
58
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Cheatsheets"), depth: 3 },
|
|
59
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Scripts"), depth: 3 },
|
|
60
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "Decisions"), depth: 3 },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "skills", label: "Skills",
|
|
65
|
+
dirs: (ctx) => [{ root: "skills", dir: path.join(ctx.claudeDir, "skills"), depth: 3 }],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "hooks", label: "Hooks",
|
|
69
|
+
dirs: (ctx) => [{ root: "hooks", dir: path.join(ctx.bundleRoot, "src", "hooks"), depth: 2 }],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "workflows", label: "Workflows",
|
|
73
|
+
dirs: (ctx) => [{ root: "workflows", dir: path.join(ctx.bundleRoot, "src", "workflows"), depth: 1 }],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "cli", label: "CLI commands",
|
|
77
|
+
notIndexable: "CLI commands are usage counts stored in your config, not files. There is no text to search.",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "people", label: "People",
|
|
81
|
+
dirs: (ctx) => [
|
|
82
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "People"), depth: 2 },
|
|
83
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "Organizations"), depth: 2 },
|
|
84
|
+
{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "Places"), depth: 2 },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "captures", label: "Captures",
|
|
89
|
+
dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Inputs"), depth: 3 }],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "archives", label: "Archives",
|
|
93
|
+
dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "04_Archives"), depth: MAX_DEPTH }],
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** Map a serving-root id to its absolute directory. Used by the file-read
|
|
98
|
+
* endpoint to resolve non-vault results with the same realpath containment
|
|
99
|
+
* it already applies to the vault. Returns null for unknown roots. */
|
|
100
|
+
function rootDirFor(ctx, root) {
|
|
101
|
+
if (root === "vault") return ctx.vault;
|
|
102
|
+
if (root === "skills") return path.join(ctx.claudeDir, "skills");
|
|
103
|
+
if (root === "workflows") return path.join(ctx.bundleRoot, "src", "workflows");
|
|
104
|
+
if (root === "hooks") return path.join(ctx.bundleRoot, "src", "hooks");
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function walkFiles(dir, maxDepth, out, state) {
|
|
109
|
+
if (state.count >= MAX_FILES_PER_SHELF) return;
|
|
110
|
+
let entries;
|
|
111
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_e) { return; }
|
|
112
|
+
for (const ent of entries) {
|
|
113
|
+
if (state.count >= MAX_FILES_PER_SHELF) return;
|
|
114
|
+
if (ent.name.startsWith(".")) continue;
|
|
115
|
+
const full = path.join(dir, ent.name);
|
|
116
|
+
if (ent.isDirectory()) {
|
|
117
|
+
if (EXCLUDED_DIRS.has(ent.name)) continue;
|
|
118
|
+
if (maxDepth > 1) walkFiles(full, maxDepth - 1, out, state);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!ent.isFile()) continue; // symlinks are not followed — containment stays real
|
|
122
|
+
out.push(full);
|
|
123
|
+
state.count += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Collect the files behind one shelf. Returns { files: [{abs, root, rootDir}], missing } */
|
|
128
|
+
function shelfFiles(shelf, ctx) {
|
|
129
|
+
const roots = shelf.dirs ? shelf.dirs(ctx) : [];
|
|
130
|
+
const files = [];
|
|
131
|
+
let anyDirExists = false;
|
|
132
|
+
const state = { count: 0 };
|
|
133
|
+
for (const r of roots) {
|
|
134
|
+
let stat = null;
|
|
135
|
+
try { stat = fs.statSync(r.dir); } catch (_e) { /* absent */ }
|
|
136
|
+
if (!stat || !stat.isDirectory()) continue;
|
|
137
|
+
anyDirExists = true;
|
|
138
|
+
const abs = [];
|
|
139
|
+
walkFiles(r.dir, r.depth || 1, abs, state);
|
|
140
|
+
for (const a of abs) files.push({ abs: a, root: r.root, rootDir: rootDirFor(ctx, r.root) });
|
|
141
|
+
}
|
|
142
|
+
return { files, missing: roots.length > 0 && !anyDirExists };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Build the passage around the first match inside a line, capped. */
|
|
146
|
+
function passageFrom(line, matchIdx, qLen) {
|
|
147
|
+
const trimmed = line.trim();
|
|
148
|
+
if (trimmed.length <= PASSAGE_CAP) return trimmed;
|
|
149
|
+
// center the window on the match
|
|
150
|
+
const lead = Math.max(0, matchIdx - Math.floor((PASSAGE_CAP - qLen) / 2));
|
|
151
|
+
const slice = line.slice(lead, lead + PASSAGE_CAP).trim();
|
|
152
|
+
return slice;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* searchLibrary(ctx, opts) — the one index.
|
|
157
|
+
* ctx = { vault, bundleRoot, claudeDir }
|
|
158
|
+
* opts = { q, shelf, limit }
|
|
159
|
+
* q — case-insensitive substring; empty q + shelf = browse listing
|
|
160
|
+
* shelf — shelf id to scope to ("" = every indexed shelf)
|
|
161
|
+
* limit — max results (1..100)
|
|
162
|
+
* Returns { q, shelf, results, truncated, scanned, coverage }.
|
|
163
|
+
*/
|
|
164
|
+
function searchLibrary(ctx, opts = {}) {
|
|
165
|
+
const q = String(opts.q || "").slice(0, 200);
|
|
166
|
+
const scope = String(opts.shelf || "");
|
|
167
|
+
const limit = Math.max(1, Math.min(MAX_LIMIT, Number(opts.limit) || DEFAULT_LIMIT));
|
|
168
|
+
const lc = q.toLowerCase();
|
|
169
|
+
|
|
170
|
+
const coverage = { indexed: [], notIndexable: [], missing: [] };
|
|
171
|
+
const results = [];
|
|
172
|
+
let scannedFiles = 0;
|
|
173
|
+
let skippedLarge = 0;
|
|
174
|
+
let truncated = false;
|
|
175
|
+
|
|
176
|
+
for (const shelf of SHELVES) {
|
|
177
|
+
if (shelf.notIndexable) {
|
|
178
|
+
coverage.notIndexable.push({ id: shelf.id, label: shelf.label, reason: shelf.notIndexable });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const { files, missing } = shelfFiles(shelf, ctx);
|
|
182
|
+
if (missing) {
|
|
183
|
+
coverage.missing.push({ id: shelf.id, label: shelf.label });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
coverage.indexed.push({ id: shelf.id, label: shelf.label, files: files.length });
|
|
187
|
+
|
|
188
|
+
if (scope && scope !== shelf.id) continue; // counted for coverage, never searched
|
|
189
|
+
|
|
190
|
+
for (const f of files) {
|
|
191
|
+
// Every skill's file is literally named SKILL.md — the skill's actual
|
|
192
|
+
// name is its folder. Rendering the basename made the whole Skills
|
|
193
|
+
// shelf read "SKILL.md, SKILL.md, ..." (owner report + terra CONFIRMED
|
|
194
|
+
// #1, 2026-07-12). Same rule for any convention-named container file.
|
|
195
|
+
const base = path.basename(f.abs);
|
|
196
|
+
let name = base;
|
|
197
|
+
if (/^(SKILL|README|INDEX)\.md$/i.test(base)) {
|
|
198
|
+
const folder = path.basename(path.dirname(f.abs));
|
|
199
|
+
if (folder && folder !== "." && folder !== path.basename(f.rootDir)) name = folder;
|
|
200
|
+
}
|
|
201
|
+
const relPath = path.relative(f.rootDir, f.abs).split(path.sep).join("/");
|
|
202
|
+
// ext/textReadable ALWAYS derive from the real file, never the display
|
|
203
|
+
// name — a folder-derived name has no extension and would silently
|
|
204
|
+
// demote the file to name-only search.
|
|
205
|
+
const ext = path.extname(base).toLowerCase();
|
|
206
|
+
const textReadable = TEXT_EXTS.has(ext);
|
|
207
|
+
|
|
208
|
+
let mtime = null;
|
|
209
|
+
let size = 0;
|
|
210
|
+
try { const st = fs.statSync(f.abs); mtime = st.mtime.toISOString(); size = st.size; } catch (_e) { continue; }
|
|
211
|
+
|
|
212
|
+
// Browse mode: empty query + a shelf scope lists the shelf's real files.
|
|
213
|
+
if (!lc) {
|
|
214
|
+
if (!scope) continue; // browsing "everything" is the catalog's job
|
|
215
|
+
results.push({
|
|
216
|
+
shelf: shelf.id, shelfLabel: shelf.label, name, relPath, root: f.root,
|
|
217
|
+
line: null, passage: null, matches: 0, nameOnly: !textReadable, mtime,
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const nameHit = name.toLowerCase().includes(lc);
|
|
223
|
+
|
|
224
|
+
if (!textReadable) {
|
|
225
|
+
// Content is not text — a name match is the only honest match.
|
|
226
|
+
if (nameHit) {
|
|
227
|
+
results.push({
|
|
228
|
+
shelf: shelf.id, shelfLabel: shelf.label, name, relPath, root: f.root,
|
|
229
|
+
line: null, passage: null, matches: 0, nameOnly: true, mtime,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (size > MAX_FILE_BYTES) { skippedLarge += 1; continue; }
|
|
236
|
+
|
|
237
|
+
let content = "";
|
|
238
|
+
try { content = fs.readFileSync(f.abs, "utf8"); } catch (_e) { continue; }
|
|
239
|
+
scannedFiles += 1;
|
|
240
|
+
|
|
241
|
+
let firstLine = null;
|
|
242
|
+
let firstPassage = null;
|
|
243
|
+
let matches = 0;
|
|
244
|
+
const lines = content.split("\n");
|
|
245
|
+
for (let i = 0; i < lines.length; i++) {
|
|
246
|
+
const idx = lines[i].toLowerCase().indexOf(lc);
|
|
247
|
+
if (idx === -1) continue;
|
|
248
|
+
matches += 1;
|
|
249
|
+
if (firstLine === null) {
|
|
250
|
+
firstLine = i + 1;
|
|
251
|
+
firstPassage = passageFrom(lines[i], idx, lc.length);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!nameHit && matches === 0) continue;
|
|
256
|
+
results.push({
|
|
257
|
+
shelf: shelf.id, shelfLabel: shelf.label, name, relPath, root: f.root,
|
|
258
|
+
line: firstLine, passage: firstPassage, matches,
|
|
259
|
+
nameOnly: matches === 0, mtime,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Rank: name matches first, then by match density, then recency.
|
|
265
|
+
if (lc) {
|
|
266
|
+
results.sort((a, b) => {
|
|
267
|
+
const an = a.name.toLowerCase().includes(lc) ? 1 : 0;
|
|
268
|
+
const bn = b.name.toLowerCase().includes(lc) ? 1 : 0;
|
|
269
|
+
if (an !== bn) return bn - an;
|
|
270
|
+
if (a.matches !== b.matches) return b.matches - a.matches;
|
|
271
|
+
return String(b.mtime || "").localeCompare(String(a.mtime || ""));
|
|
272
|
+
});
|
|
273
|
+
} else {
|
|
274
|
+
results.sort((a, b) => String(b.mtime || "").localeCompare(String(a.mtime || "")));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (results.length > limit) {
|
|
278
|
+
results.length = limit;
|
|
279
|
+
truncated = true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
q, shelf: scope || "all",
|
|
284
|
+
results, truncated,
|
|
285
|
+
scanned: { files: scannedFiles, skippedLarge },
|
|
286
|
+
coverage,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = { searchLibrary, rootDirFor, SHELVES };
|
|
File without changes
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
const fs = require("fs");
|
|
17
17
|
const path = require("path");
|
|
18
|
+
const crypto = require("crypto");
|
|
18
19
|
const { tokenize, termFreq, buildVocab } = require("./memory-text");
|
|
19
20
|
const { enumerateSessions, parseTranscript, deriveProject } = require("./transcript-parser");
|
|
20
21
|
const { parseSessionLogs } = require("./session-log-parser");
|
|
@@ -46,9 +47,34 @@ function ensureDirs() {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
function atomicWrite(file, obj) {
|
|
49
|
-
|
|
50
|
+
// terra T-MH-2: a fixed `file + ".tmp"` name means two concurrent SessionEnd
|
|
51
|
+
// hooks writing the same segment/manifest share one temp path and can interleave
|
|
52
|
+
// into a torn file. A per-write unique temp name makes each write's rename
|
|
53
|
+
// independent (the last full write wins cleanly; no half-written temp survives).
|
|
54
|
+
const tmp = file + "." + process.pid + "-" + crypto.randomBytes(4).toString("hex") + ".tmp";
|
|
50
55
|
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
51
|
-
fs.renameSync(tmp, file);
|
|
56
|
+
try { fs.renameSync(tmp, file); }
|
|
57
|
+
catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// terra T-MH-1: safeReadJson() collapses "file absent" and "file present but
|
|
61
|
+
// MALFORMED" both to null, so `safeReadJson(segFile) || newSeg()` would overwrite
|
|
62
|
+
// a corrupt segment with an empty one — discarding every indexed doc for that
|
|
63
|
+
// month. Distinguish the two: a MISSING segment is a fresh newSeg(); a present-
|
|
64
|
+
// but-unparseable segment THROWS, so the fail-open SessionEnd hook skips indexing
|
|
65
|
+
// this one session and PRESERVES the existing file rather than nuking it.
|
|
66
|
+
function readSegOrNew(segFile) {
|
|
67
|
+
let raw;
|
|
68
|
+
try { raw = fs.readFileSync(segFile, "utf8"); }
|
|
69
|
+
catch (e) { if (e.code === "ENOENT") return newSeg(); throw e; }
|
|
70
|
+
let parsed;
|
|
71
|
+
try { parsed = JSON.parse(raw); }
|
|
72
|
+
catch (_) { throw Object.assign(new Error("memory segment is malformed; refusing to overwrite it: " + segFile), { code: "ESEGCORRUPT" }); }
|
|
73
|
+
// A parsed-but-wrong-shape file is also not safe to fold into — treat as corrupt.
|
|
74
|
+
if (!parsed || typeof parsed !== "object" || !parsed.docs || !parsed.postings) {
|
|
75
|
+
throw Object.assign(new Error("memory segment has an unexpected shape; refusing to overwrite it: " + segFile), { code: "ESEGCORRUPT" });
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
52
78
|
}
|
|
53
79
|
|
|
54
80
|
function monthOf(ts, fallbackMs) {
|
|
@@ -377,19 +403,43 @@ async function indexSession(file, opts = {}) {
|
|
|
377
403
|
if (!man) return { error: "no index — run --backfill first" };
|
|
378
404
|
man.docFreq = man.docFreq || {}; man.linkFreq = man.linkFreq || {}; man.indexedSessions = man.indexedSessions || {};
|
|
379
405
|
const vault = opts.vault || man.vault;
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
// when not passed — the SessionEnd hook calls indexSession(file, {}), and without this every
|
|
383
|
-
// live-indexed session would be project=null, making the cross-project scope guard a no-op.
|
|
384
|
-
const project = opts.project || deriveProject(path.basename(path.dirname(file))) || null;
|
|
406
|
+
const pathSessionId = path.basename(file).replace(/\.jsonl$/, "");
|
|
407
|
+
const pathProject = deriveProject(path.basename(path.dirname(file))) || null;
|
|
385
408
|
let mtime = null, size = 0;
|
|
386
409
|
try { const st = fs.statSync(file); mtime = st.mtimeMs; size = st.size; } catch {}
|
|
387
|
-
const prev = man.indexedSessions[sessionId];
|
|
388
|
-
if (prev && mtime && prev.mtime === mtime && !opts.force) return { skipped: true, sessionId };
|
|
389
410
|
|
|
390
|
-
|
|
411
|
+
// Parse before selecting identity. Claude stores sessions under an encoded project directory, but
|
|
412
|
+
// Codex stores rollouts under date folders and carries the real session id + cwd in session_meta.
|
|
413
|
+
const r = await parseTranscript(file, {
|
|
414
|
+
vocab: buildVocab(vault),
|
|
415
|
+
sessionId: opts.sessionId,
|
|
416
|
+
project: opts.project,
|
|
417
|
+
agent: opts.agent,
|
|
418
|
+
});
|
|
419
|
+
const sessionId = opts.sessionId || r.sessionId || pathSessionId;
|
|
420
|
+
const project = opts.project || r.project || pathProject;
|
|
391
421
|
if (r.error) return { error: "parse failed", sessionId };
|
|
392
422
|
|
|
423
|
+
let reindexed = false;
|
|
424
|
+
// Remove a pre-fix Codex entry keyed by the rollout filename. Without this migration the same
|
|
425
|
+
// transcript survives twice: once under rollout-* and once under its canonical session id.
|
|
426
|
+
if (pathSessionId !== sessionId) {
|
|
427
|
+
const legacy = man.indexedSessions[pathSessionId];
|
|
428
|
+
if (legacy && legacy.month) {
|
|
429
|
+
const legacyFile = path.join(SEG_DIR, "raw-" + legacy.month + ".json");
|
|
430
|
+
const legacySeg = safeReadJson(legacyFile);
|
|
431
|
+
if (legacySeg && legacySeg.docs["session:" + pathSessionId]) {
|
|
432
|
+
removeDocContribution(legacySeg, "session:" + pathSessionId, man);
|
|
433
|
+
atomicWrite(legacyFile, legacySeg);
|
|
434
|
+
}
|
|
435
|
+
delete man.indexedSessions[pathSessionId];
|
|
436
|
+
reindexed = true;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const prev = man.indexedSessions[sessionId];
|
|
441
|
+
if (prev && mtime && prev.mtime === mtime && !opts.force && !reindexed) return { skipped: true, sessionId };
|
|
442
|
+
|
|
393
443
|
const month = monthOf(r.firstTs, mtime);
|
|
394
444
|
const id = "session:" + sessionId;
|
|
395
445
|
|
|
@@ -398,7 +448,6 @@ async function indexSession(file, opts = {}) {
|
|
|
398
448
|
// lives in the PREVIOUSLY recorded month's segment — remove it from THERE too, else it orphans
|
|
399
449
|
// (ghost doc) and permanently inflates docFreq/totalDocs/linkFreq, degrading IDF until a full
|
|
400
450
|
// --backfill. Only fires on a genuine month change; the common same-month re-index skips it.
|
|
401
|
-
let reindexed = false;
|
|
402
451
|
const prevMonth = prev && prev.month;
|
|
403
452
|
if (prevMonth && prevMonth !== month) {
|
|
404
453
|
const prevSegFile = path.join(SEG_DIR, "raw-" + prevMonth + ".json");
|
|
@@ -411,7 +460,7 @@ async function indexSession(file, opts = {}) {
|
|
|
411
460
|
}
|
|
412
461
|
|
|
413
462
|
const segFile = path.join(SEG_DIR, "raw-" + month + ".json");
|
|
414
|
-
const seg =
|
|
463
|
+
const seg = readSegOrNew(segFile); // terra T-MH-1: never clobber a malformed segment
|
|
415
464
|
|
|
416
465
|
// remove old contribution (same-month re-index case) — scan only this segment's postings.
|
|
417
466
|
if (seg.docs[id]) { removeDocContribution(seg, id, man); reindexed = true; }
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* pid-markers.js — crash-orphan bookkeeping shared by AgentSessionManager
|
|
5
|
+
* (Chat/onboarding) and RunRegistry (Run tab).
|
|
6
|
+
*
|
|
7
|
+
* Every dashboard-spawned agent child is detached into its own process group
|
|
8
|
+
* and recorded here as one marker file per pid, tagged with the owning
|
|
9
|
+
* dashboard's pid. On startup each manager reaps any group whose OWNER is
|
|
10
|
+
* gone — children escaped by a crashed dashboard (kill -9, OOM, update crash)
|
|
11
|
+
* that no graceful shutdown path could ever reach.
|
|
12
|
+
*
|
|
13
|
+
* Extracted verbatim from agent-session.js (MF2, Codex r2 hardening) so the
|
|
14
|
+
* Run tab gets the same guarantee. One file per pid: no shared-file
|
|
15
|
+
* read-modify-write race across concurrent dashboards; a corrupt write only
|
|
16
|
+
* loses its own marker. Writes are atomic (tmp + rename). Both managers share
|
|
17
|
+
* ONE directory on purpose: whichever starts first reaps the other's orphans.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const os = require("os");
|
|
21
|
+
const path = require("path");
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
|
|
24
|
+
const MARKERS_DIR = path.join(os.homedir(), ".mover", "dashboard", "agent-pids");
|
|
25
|
+
|
|
26
|
+
function markerPath(pid) { return path.join(MARKERS_DIR, String(pid) + ".json"); }
|
|
27
|
+
|
|
28
|
+
function writeMarker(pid, data) {
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(MARKERS_DIR, { recursive: true });
|
|
31
|
+
const f = markerPath(pid), tmp = f + ".tmp";
|
|
32
|
+
fs.writeFileSync(tmp, JSON.stringify(data));
|
|
33
|
+
fs.renameSync(tmp, f); // atomic: a reader never sees a half-written marker
|
|
34
|
+
} catch (_) {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function removeMarker(pid) { try { fs.unlinkSync(markerPath(pid)); } catch (_) {} }
|
|
38
|
+
|
|
39
|
+
function readMarkers() {
|
|
40
|
+
let files; try { files = fs.readdirSync(MARKERS_DIR); } catch (_) { return []; }
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const fn of files) {
|
|
43
|
+
if (!fn.endsWith(".json")) continue;
|
|
44
|
+
try { out.push(JSON.parse(fs.readFileSync(path.join(MARKERS_DIR, fn), "utf8"))); } catch (_) {}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isAlive(pid) {
|
|
50
|
+
if (!pid) return false;
|
|
51
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// True if ANY member of the process group still exists (not just the leader) —
|
|
55
|
+
// process.kill(-pid, 0) throws ESRCH only when the whole group is gone.
|
|
56
|
+
function groupAlive(pid) {
|
|
57
|
+
if (!pid) return false;
|
|
58
|
+
try { process.kill(-pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function killGroup(pid, sig) {
|
|
62
|
+
if (!pid) return false;
|
|
63
|
+
try { process.kill(-pid, sig); return true; }
|
|
64
|
+
catch (_) { try { process.kill(pid, sig); return true; } catch (__) { return false; } }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Reap any recorded group whose owning dashboard is gone. Idempotent; safe to
|
|
68
|
+
// call from every manager's constructor (they share the directory).
|
|
69
|
+
function reapOrphans() {
|
|
70
|
+
for (const m of readMarkers()) {
|
|
71
|
+
if (!m || !m.pid) continue;
|
|
72
|
+
// Keep markers whose owning dashboard is still alive (this process OR a
|
|
73
|
+
// concurrent one). Only a marker whose owner is GONE is a real orphan.
|
|
74
|
+
if (m.ownerPid && isAlive(m.ownerPid)) continue;
|
|
75
|
+
if (groupAlive(m.pid)) killGroup(m.pid, "SIGKILL"); // owner gone → orphaned group
|
|
76
|
+
removeMarker(m.pid);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { MARKERS_DIR, writeMarker, removeMarker, readMarkers, isAlive, groupAlive, killGroup, reapOrphans };
|
|
File without changes
|
|
@@ -29,9 +29,11 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
const { spawn } = require("child_process");
|
|
32
|
+
const { writeMarker, removeMarker, groupAlive, killGroup, reapOrphans } = require("./pid-markers");
|
|
32
33
|
|
|
33
34
|
const DEFAULTS = {
|
|
34
35
|
maxRuns: 40, // retain at most N runs (live + finished)
|
|
36
|
+
maxConcurrent: 4, // live child processes at once; excess requests are refused
|
|
35
37
|
maxBuffer: 1024 * 1024, // 1 MB per-run output cap (head dropped, tail kept)
|
|
36
38
|
finishedTtlMs: 6 * 60 * 60 * 1000, // evict finished runs 6h after they end
|
|
37
39
|
idleSweepMs: 5 * 60 * 1000, // sweep cadence
|
|
@@ -43,10 +45,21 @@ class RunRegistry {
|
|
|
43
45
|
this.opt = { ...DEFAULTS, ...opts };
|
|
44
46
|
this.runs = new Map(); // id -> run record
|
|
45
47
|
this._seq = 0;
|
|
48
|
+
// R5-A: reap children escaped by a crashed dashboard (same MF2 guarantee
|
|
49
|
+
// AgentSessionManager has; the marker dir is shared so either manager's
|
|
50
|
+
// startup cleans up both kinds of orphan).
|
|
51
|
+
reapOrphans();
|
|
46
52
|
this._sweep = setInterval(() => this._evict(), this.opt.idleSweepMs);
|
|
47
53
|
if (this._sweep.unref) this._sweep.unref();
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/** Live (running/stopping) child count — the concurrency the cap guards. */
|
|
57
|
+
liveCount() {
|
|
58
|
+
let n = 0;
|
|
59
|
+
for (const r of this.runs.values()) if (r.status === "running" || r.status === "stopping") n++;
|
|
60
|
+
return n;
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
/**
|
|
51
64
|
* Spawn a tracked run. The CALLER builds the command (agentCommand) and the
|
|
52
65
|
* final prompt (buildAgentPrompt); this owns the process + buffer lifecycle.
|
|
@@ -54,6 +67,13 @@ class RunRegistry {
|
|
|
54
67
|
* Returns the run record (its `.id` is the handle for subscribe/get/stop).
|
|
55
68
|
*/
|
|
56
69
|
start(spec) {
|
|
70
|
+
// Concurrency cap (R5-A): every run is a real long-lived CLI child with
|
|
71
|
+
// full tool access; a buggy retry loop must not fork-bomb the machine.
|
|
72
|
+
if (this.liveCount() >= this.opt.maxConcurrent) {
|
|
73
|
+
const err = new Error(`${this.opt.maxConcurrent} agents are already running. Stop one or let it finish first.`);
|
|
74
|
+
err.code = "RUN_LIMIT";
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
57
77
|
const id = `run_${process.pid}_${++this._seq}`;
|
|
58
78
|
const run = {
|
|
59
79
|
id,
|
|
@@ -73,17 +93,26 @@ class RunRegistry {
|
|
|
73
93
|
|
|
74
94
|
let child;
|
|
75
95
|
try {
|
|
76
|
-
|
|
96
|
+
// detached: own process group, so Stop/shutdown can signal the whole
|
|
97
|
+
// tree (a run's tool calls spawn grandchildren the bare child.kill()
|
|
98
|
+
// never reached), and the pid marker lets a post-crash startup reap it.
|
|
99
|
+
child = spawn(spec.cmd, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
77
100
|
} catch (e) {
|
|
78
101
|
this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${e && e.message}\n`);
|
|
79
102
|
this._finish(run, "failed", null);
|
|
80
103
|
return run;
|
|
81
104
|
}
|
|
82
105
|
run.child = child;
|
|
106
|
+
if (child.pid) writeMarker(child.pid, { pid: child.pid, ownerPid: process.pid, at: Date.now(), kind: "run" });
|
|
83
107
|
|
|
84
108
|
const timeout = setTimeout(() => {
|
|
109
|
+
// Mark stopping BEFORE the kill so the close handler reports "stopped",
|
|
110
|
+
// not a false "done" (terra run-path F2).
|
|
111
|
+
run.status = "stopping";
|
|
85
112
|
this._append(run, "\n[mover-studio] timeout after 10 minutes; stopping agent.\n", true);
|
|
86
|
-
|
|
113
|
+
killGroup(child.pid, "SIGTERM");
|
|
114
|
+
const esc = setTimeout(() => { if (groupAlive(child.pid)) killGroup(child.pid, "SIGKILL"); }, 2500);
|
|
115
|
+
if (esc.unref) esc.unref();
|
|
87
116
|
}, this.opt.timeoutMs);
|
|
88
117
|
if (timeout.unref) timeout.unref();
|
|
89
118
|
|
|
@@ -102,12 +131,14 @@ class RunRegistry {
|
|
|
102
131
|
this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${err && err.message}\n`, true);
|
|
103
132
|
this._finish(run, "failed", null);
|
|
104
133
|
});
|
|
105
|
-
child.on("close", (code) => {
|
|
134
|
+
child.on("close", (code, signal) => {
|
|
106
135
|
clearTimeout(timeout);
|
|
107
|
-
this._append(run, `\n[mover-studio] agent exited with code ${code}\n`, true);
|
|
108
|
-
// A run we SIGTERM'd
|
|
109
|
-
//
|
|
110
|
-
|
|
136
|
+
this._append(run, `\n[mover-studio] agent exited with code ${code}${signal ? ` (signal ${signal})` : ""}\n`, true);
|
|
137
|
+
// A run we SIGTERM'd (stop OR timeout) keeps "stopping" -> "stopped". A
|
|
138
|
+
// clean exit (code 0) is "done"; ANY other termination — a non-zero code OR
|
|
139
|
+
// code==null from a signal we did not send — is "exited", never a false
|
|
140
|
+
// "done" (terra run-path F2: a signal-killed run was reported as success).
|
|
141
|
+
const status = run.status === "stopping" ? "stopped" : code === 0 ? "done" : "exited";
|
|
111
142
|
this._finish(run, status, code);
|
|
112
143
|
});
|
|
113
144
|
|
|
@@ -123,6 +154,10 @@ class RunRegistry {
|
|
|
123
154
|
// Snapshot + register atomically (no I/O between) so no chunk is lost.
|
|
124
155
|
cb({ type: "replay", text: run.output, status: run.status, exit: run.exit });
|
|
125
156
|
if (run.status === "running" || run.status === "stopping") {
|
|
157
|
+
// Cap live subscribers per run so a flood of stream connections can't pile
|
|
158
|
+
// up callbacks/sockets (terra run-path F3). Over the cap, the client still
|
|
159
|
+
// got the full replay above; end it rather than live-stream.
|
|
160
|
+
if (run.subscribers.size >= 64) { cb({ type: "end", status: run.status, exit: run.exit }); return () => {}; }
|
|
126
161
|
run.subscribers.add(cb);
|
|
127
162
|
return () => run.subscribers.delete(cb);
|
|
128
163
|
}
|
|
@@ -130,15 +165,17 @@ class RunRegistry {
|
|
|
130
165
|
return () => {};
|
|
131
166
|
}
|
|
132
167
|
|
|
133
|
-
/** Really stop a run (SIGTERM the child
|
|
168
|
+
/** Really stop a run (SIGTERM the child's whole process group — a run's
|
|
169
|
+
* tool calls spawn grandchildren). Distinct from a client merely
|
|
134
170
|
* detaching its stream ("stop watching"). */
|
|
135
171
|
stop(id) {
|
|
136
172
|
const run = this.runs.get(id);
|
|
137
173
|
if (!run || !run.child || (run.status !== "running")) return false;
|
|
138
174
|
run.status = "stopping";
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
175
|
+
const pid = run.child.pid;
|
|
176
|
+
killGroup(pid, "SIGTERM");
|
|
177
|
+
// Escalate if the group ignores SIGTERM.
|
|
178
|
+
const t = setTimeout(() => { if (run.status === "stopping" && groupAlive(pid)) killGroup(pid, "SIGKILL"); }, 2500);
|
|
142
179
|
if (t.unref) t.unref();
|
|
143
180
|
return true;
|
|
144
181
|
}
|
|
@@ -149,11 +186,30 @@ class RunRegistry {
|
|
|
149
186
|
return [...this.runs.values()].sort((a, b) => b.started - a.started).map((r) => this._summary(r, false));
|
|
150
187
|
}
|
|
151
188
|
|
|
152
|
-
|
|
189
|
+
/** Kill every live run's process group and WAIT for it to die, escalating
|
|
190
|
+
* to SIGKILL after graceMs — mirrors AgentSessionManager.shutdownAll (MF1)
|
|
191
|
+
* so the server's hard-exit timer can't strand a stubborn child. If a
|
|
192
|
+
* group somehow survives SIGKILL, its marker stays on disk so the next
|
|
193
|
+
* startup reaps it — never a silent orphan. */
|
|
194
|
+
shutdownAll(graceMs = 1500) {
|
|
153
195
|
if (this._sweep) { clearInterval(this._sweep); this._sweep = null; }
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
196
|
+
const live = [...this.runs.values()].filter((r) => r.child && (r.status === "running" || r.status === "stopping"));
|
|
197
|
+
return Promise.all(live.map((r) => new Promise((resolve) => {
|
|
198
|
+
const pid = r.child.pid;
|
|
199
|
+
killGroup(pid, "SIGTERM");
|
|
200
|
+
const start = Date.now();
|
|
201
|
+
let killed = false;
|
|
202
|
+
const tick = () => {
|
|
203
|
+
if (!groupAlive(pid)) { removeMarker(pid); return resolve(); }
|
|
204
|
+
if (!killed && Date.now() - start >= graceMs) { killGroup(pid, "SIGKILL"); killed = true; }
|
|
205
|
+
if (killed && Date.now() - start >= graceMs + 700) {
|
|
206
|
+
if (!groupAlive(pid)) removeMarker(pid);
|
|
207
|
+
return resolve();
|
|
208
|
+
}
|
|
209
|
+
const t = setTimeout(tick, 150); if (t.unref) t.unref();
|
|
210
|
+
};
|
|
211
|
+
tick();
|
|
212
|
+
})));
|
|
157
213
|
}
|
|
158
214
|
|
|
159
215
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
@@ -185,6 +241,10 @@ class RunRegistry {
|
|
|
185
241
|
// "failed"/"stopped" with the close code (null -> "done") and emits a
|
|
186
242
|
// second "end". First finish wins. (Found by a Codex review, 2026-06-30.)
|
|
187
243
|
if (run.ended) return;
|
|
244
|
+
// Drop the crash marker only once the whole GROUP is gone; if a tool
|
|
245
|
+
// grandchild outlived the leader, the marker survives and the next
|
|
246
|
+
// dashboard startup reaps it.
|
|
247
|
+
if (run.child && run.child.pid && !groupAlive(run.child.pid)) removeMarker(run.child.pid);
|
|
188
248
|
run.status = status;
|
|
189
249
|
run.exit = exit;
|
|
190
250
|
run.ended = Date.now();
|