@agimon-ai/po-file-edit 0.0.0-alpha.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.
@@ -0,0 +1,1934 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
5
+ //#region \0rolldown/runtime.js
6
+ var __create = Object.create;
7
+ var __defProp = Object.defineProperty;
8
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
9
+ var __getOwnPropNames = Object.getOwnPropertyNames;
10
+ var __getProtoOf = Object.getPrototypeOf;
11
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
14
+ key = keys[i];
15
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
16
+ get: ((k) => from[k]).bind(null, key),
17
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
18
+ });
19
+ }
20
+ return to;
21
+ };
22
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
23
+ value: mod,
24
+ enumerable: true
25
+ }) : target, mod));
26
+ //#endregion
27
+ let _agimon_ai_po_core_serverFacet = require("@agimon-ai/po-core/serverFacet");
28
+ let _agimon_ai_po_core_extensionFile = require("@agimon-ai/po-core/extensionFile");
29
+ let node_fs = require("node:fs");
30
+ node_fs = __toESM(node_fs, 1);
31
+ let node_path = require("node:path");
32
+ node_path = __toESM(node_path, 1);
33
+ let _agimon_ai_po_core_childProcess = require("@agimon-ai/po-core/childProcess");
34
+ let _agimon_ai_po_telemetry = require("@agimon-ai/po-telemetry");
35
+ let ignore = require("ignore");
36
+ ignore = __toESM(ignore, 1);
37
+ let node_child_process = require("node:child_process");
38
+ let node_crypto = require("node:crypto");
39
+ let _agimon_ai_po_core_piSettings = require("@agimon-ai/po-core/piSettings");
40
+ let node_util = require("node:util");
41
+ let node_fs_promises = require("node:fs/promises");
42
+ node_fs_promises = __toESM(node_fs_promises, 1);
43
+ let hono = require("hono");
44
+ let _agimon_ai_po_core_web = require("@agimon-ai/po-core/web");
45
+ //#region src/services/doomIgnore/index.ts
46
+ function matchablePath(relPath) {
47
+ const normalized = relPath.replace(/\\/gu, "/");
48
+ if (normalized.length === 0 || normalized.includes("\0") || normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) return;
49
+ return normalized;
50
+ }
51
+ /**
52
+ * Builds the project's ignore test, or nothing when there are no rules.
53
+ *
54
+ * Shared because the same rules are needed twice: once when deciding what to
55
+ * record, and once when deciding what to list. A caller that built its own
56
+ * matcher would let the two drift apart.
57
+ *
58
+ * The test answers false for anything it cannot safely match, so a path that
59
+ * escapes the project stays visible rather than being hidden by accident.
60
+ */
61
+ function createDoomIgnoreMatcher(content) {
62
+ try {
63
+ if (content.trim().length === 0) return void 0;
64
+ const matcher = (0, ignore.default)().add(content);
65
+ return (relPath) => {
66
+ const candidate = matchablePath(relPath);
67
+ return candidate !== void 0 && matcher.ignores(candidate);
68
+ };
69
+ } catch {
70
+ return;
71
+ }
72
+ }
73
+ /** Applies project-local gitignore rules while keeping unsafe candidates visible. */
74
+ function filterDoomIgnoredFiles(items, content) {
75
+ const ignored = createDoomIgnoreMatcher(content);
76
+ if (ignored === void 0) return items;
77
+ return items.filter((item) => !ignored(item.relPath));
78
+ }
79
+ //#endregion
80
+ //#region src/services/lineDiff/index.ts
81
+ /**
82
+ * The line difference between two versions of a file, as bounded hunks.
83
+ *
84
+ * This is deliberately the package's own rather than Pi's `generateDiffString`:
85
+ * that helper reaches the session process through an optional peer dependency,
86
+ * and these routes run in a host that imports only the built entry, so its
87
+ * presence is an assumption rather than a fact. A line diff is small enough to
88
+ * own outright, and owning it means the wire carries structured rows instead of
89
+ * a formatted string the browser has to parse back apart.
90
+ *
91
+ * Bounded twice over. Common prefix and suffix are trimmed first, which is what
92
+ * makes a one-line change in a large file cheap. What remains is matched with a
93
+ * longest-common-subsequence table, and only while that table stays under
94
+ * MAX_MATRIX_CELLS: past it the changed region is reported as one replacement
95
+ * rather than growing a matrix that would outweigh the file.
96
+ */
97
+ /** Lines of unchanged context kept either side of a change. */
98
+ const CONTEXT_LINES = 3;
99
+ /** The largest LCS table worth building; past it the changed region is one replacement. */
100
+ const MAX_MATRIX_CELLS = 1e6;
101
+ function splitLines(text) {
102
+ const normalized = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
103
+ return normalized === "" ? [] : normalized.split("\n");
104
+ }
105
+ /** How many leading lines the two sides share. */
106
+ function commonPrefix(before, after) {
107
+ const limit = Math.min(before.length, after.length);
108
+ let count = 0;
109
+ while (count < limit && before[count] === after[count]) count += 1;
110
+ return count;
111
+ }
112
+ /** How many trailing lines the two sides share, never overlapping the prefix. */
113
+ function commonSuffix(before, after, prefix) {
114
+ const limit = Math.min(before.length, after.length) - prefix;
115
+ let count = 0;
116
+ while (count < limit && before[before.length - 1 - count] === after[after.length - 1 - count]) count += 1;
117
+ return count;
118
+ }
119
+ /**
120
+ * The classic LCS length table. Only ever called once the caller has checked
121
+ * the region against MAX_MATRIX_CELLS, so the allocation is bounded.
122
+ */
123
+ function lcsTable(before, after) {
124
+ const rows = before.length + 1;
125
+ const columns = after.length + 1;
126
+ const table = new Uint32Array(rows * columns);
127
+ for (let row = before.length - 1; row >= 0; row -= 1) for (let column = after.length - 1; column >= 0; column -= 1) {
128
+ const index = row * columns + column;
129
+ table[index] = before[row] === after[column] ? (table[index + columns + 1] ?? 0) + 1 : Math.max(table[index + columns] ?? 0, table[index + 1] ?? 0);
130
+ }
131
+ return table;
132
+ }
133
+ /** Walks the table to turn it back into an ordered run of keeps, removals and additions. */
134
+ function backtrack(before, after, beforeStart, afterStart) {
135
+ const table = lcsTable(before, after);
136
+ const columns = after.length + 1;
137
+ const steps = [];
138
+ let row = 0;
139
+ let column = 0;
140
+ while (row < before.length && column < after.length) {
141
+ if (before[row] === after[column]) {
142
+ steps.push({
143
+ marker: " ",
144
+ content: after[column] ?? "",
145
+ line: afterStart + column
146
+ });
147
+ row += 1;
148
+ column += 1;
149
+ continue;
150
+ }
151
+ if ((table[(row + 1) * columns + column] ?? 0) >= (table[row * columns + column + 1] ?? 0)) {
152
+ steps.push({
153
+ marker: "-",
154
+ content: before[row] ?? "",
155
+ line: beforeStart + row
156
+ });
157
+ row += 1;
158
+ continue;
159
+ }
160
+ steps.push({
161
+ marker: "+",
162
+ content: after[column] ?? "",
163
+ line: afterStart + column
164
+ });
165
+ column += 1;
166
+ }
167
+ while (row < before.length) {
168
+ steps.push({
169
+ marker: "-",
170
+ content: before[row] ?? "",
171
+ line: beforeStart + row
172
+ });
173
+ row += 1;
174
+ }
175
+ while (column < after.length) {
176
+ steps.push({
177
+ marker: "+",
178
+ content: after[column] ?? "",
179
+ line: afterStart + column
180
+ });
181
+ column += 1;
182
+ }
183
+ return steps;
184
+ }
185
+ /** The whole changed region as one replacement, for a region too large to match. */
186
+ function wholesale(before, after, beforeStart, afterStart) {
187
+ return [...before.map((content, offset) => ({
188
+ marker: "-",
189
+ content,
190
+ line: beforeStart + offset
191
+ })), ...after.map((content, offset) => ({
192
+ marker: "+",
193
+ content,
194
+ line: afterStart + offset
195
+ }))];
196
+ }
197
+ /** Groups the steps into hunks, keeping CONTEXT_LINES of unchanged text either side of a change. */
198
+ function toHunks(steps) {
199
+ const keep = Array.from({ length: steps.length }, () => false);
200
+ for (const [index, step] of steps.entries()) {
201
+ if (step.marker === " ") continue;
202
+ const from = Math.max(0, index - CONTEXT_LINES);
203
+ const to = Math.min(steps.length - 1, index + CONTEXT_LINES);
204
+ for (let cursor = from; cursor <= to; cursor += 1) keep[cursor] = true;
205
+ }
206
+ const hunks = [];
207
+ let rows = [];
208
+ for (const [index, step] of steps.entries()) {
209
+ if (!keep[index]) {
210
+ if (rows.length > 0) {
211
+ hunks.push({
212
+ start: rows[0]?.line ?? 1,
213
+ rows
214
+ });
215
+ rows = [];
216
+ }
217
+ continue;
218
+ }
219
+ rows.push({
220
+ marker: step.marker,
221
+ line: step.line,
222
+ content: step.content
223
+ });
224
+ }
225
+ if (rows.length > 0) hunks.push({
226
+ start: rows[0]?.line ?? 1,
227
+ rows
228
+ });
229
+ return hunks;
230
+ }
231
+ /**
232
+ * The difference between a file that did not exist and the one that now does.
233
+ *
234
+ * Kept separate from a two-sided diff because an empty baseline is the one case
235
+ * where the trailing newline matters. `splitLines` reads "a\n" as a line and
236
+ * then an empty one, which is invisible in a normal diff because both sides
237
+ * carry it and it cancels, but against nothing it would report a file of three
238
+ * lines as four. A creation is counted on the lines the file actually holds.
239
+ */
240
+ function lineDiffFromEmpty(after) {
241
+ return lineDiff("", after.endsWith("\n") ? after.slice(0, -1) : after);
242
+ }
243
+ /**
244
+ * The difference between two versions of a file.
245
+ *
246
+ * Line numbers are the new file's for context and additions, and the old
247
+ * file's for removals, which is the pairing a reader expects: the number
248
+ * beside a line is where that line actually lives.
249
+ */
250
+ function lineDiff(before, after) {
251
+ const beforeLines = splitLines(before);
252
+ const afterLines = splitLines(after);
253
+ const prefix = commonPrefix(beforeLines, afterLines);
254
+ const suffix = commonSuffix(beforeLines, afterLines, prefix);
255
+ const beforeMiddle = beforeLines.slice(prefix, beforeLines.length - suffix);
256
+ const afterMiddle = afterLines.slice(prefix, afterLines.length - suffix);
257
+ const approximate = beforeMiddle.length * afterMiddle.length > MAX_MATRIX_CELLS;
258
+ const middle = approximate ? wholesale(beforeMiddle, afterMiddle, prefix + 1, prefix + 1) : backtrack(beforeMiddle, afterMiddle, prefix + 1, prefix + 1);
259
+ const head = beforeLines.slice(0, prefix).map((content, offset) => ({
260
+ marker: " ",
261
+ content,
262
+ line: offset + 1
263
+ }));
264
+ const tail = afterLines.slice(afterLines.length - suffix).map((content, offset) => ({
265
+ marker: " ",
266
+ content,
267
+ line: afterLines.length - suffix + offset + 1
268
+ }));
269
+ const steps = [
270
+ ...head,
271
+ ...middle,
272
+ ...tail
273
+ ];
274
+ return {
275
+ hunks: toHunks(steps),
276
+ additions: steps.filter((step) => step.marker === "+").length,
277
+ removals: steps.filter((step) => step.marker === "-").length,
278
+ approximate
279
+ };
280
+ }
281
+ //#endregion
282
+ //#region src/services/editTracker/index.ts
283
+ const EDIT_TOOL = "edit";
284
+ const WRITE_TOOL = "write";
285
+ const BASH_TOOL = "bash";
286
+ function objectValue(value, key) {
287
+ if (!value || typeof value !== "object") return void 0;
288
+ const found = value[key];
289
+ return typeof found === "string" ? found : void 0;
290
+ }
291
+ /**
292
+ * How far before a call started a write may claim to have happened and still be
293
+ * attributed to it. A working tree can sit on a filesystem that stores whole
294
+ * seconds, which floors a write made just after the call began to a stamp just
295
+ * before it, and the cost of being generous here is one second of the staleness
296
+ * this check exists to remove.
297
+ */
298
+ const MODIFIED_TOLERANCE_MS = 1e3;
299
+ var EditTracker = class {
300
+ timeline;
301
+ snapshots;
302
+ manifests;
303
+ pending = /* @__PURE__ */ new Map();
304
+ /** Start time per in-flight bash call, so its end knows what it may claim. */
305
+ bracketed = /* @__PURE__ */ new Map();
306
+ /**
307
+ * The tree as this tracker last saw it. A bash call compares against it and
308
+ * then replaces it, so each call costs one walk rather than two, and a change
309
+ * made between calls is still noticed on the next one.
310
+ */
311
+ manifest;
312
+ /**
313
+ * This package's own storage, which the walk must never report. The timeline
314
+ * and its snapshots can land inside the tree being watched, and recording
315
+ * them would make every change cause another one.
316
+ */
317
+ excluded = [];
318
+ /**
319
+ * The project's own ignore rules. A path the project disowns is not worth a
320
+ * git call, a read, or a stored copy of its content, so it is dropped before
321
+ * it costs any of them.
322
+ */
323
+ isIgnored;
324
+ /**
325
+ * The last content hash this session saw per path, from either mechanism.
326
+ * A candidate whose hash has not moved was touched, not edited.
327
+ */
328
+ contents = /* @__PURE__ */ new Map();
329
+ now;
330
+ git;
331
+ /**
332
+ * The scan deferred by the last bash call. Every bracket joins this chain, so
333
+ * at most one walk runs at a time and each one sees the baseline the previous
334
+ * one left behind.
335
+ */
336
+ scanning = Promise.resolve();
337
+ constructor(timeline, snapshots, manifests, options = {}) {
338
+ this.timeline = timeline;
339
+ this.snapshots = snapshots;
340
+ this.manifests = manifests;
341
+ this.now = options.now ?? Date.now;
342
+ this.git = options.git;
343
+ }
344
+ /**
345
+ * Forgets the previous session's tree so a new one does not inherit its
346
+ * baseline, and takes the paths this session's own bookkeeping occupies.
347
+ */
348
+ reset(options = {}) {
349
+ this.pending.clear();
350
+ this.bracketed.clear();
351
+ this.contents.clear();
352
+ this.manifest = void 0;
353
+ this.scanning = Promise.resolve();
354
+ this.excluded = options.exclude ?? [];
355
+ this.isIgnored = options.isIgnored;
356
+ }
357
+ /**
358
+ * Awaits a scan still running in the background. A caller that is about to
359
+ * clear the timeline needs this, or the scan appends after the clear.
360
+ */
361
+ async flush() {
362
+ await this.scanning;
363
+ }
364
+ async start(id, tool, args, cwd) {
365
+ await this.scanning;
366
+ if (tool === EDIT_TOOL || tool === WRITE_TOOL) {
367
+ const supplied = objectValue(args, "path");
368
+ if (!supplied) return;
369
+ const filePath = node_path.default.resolve(cwd, supplied);
370
+ const [before, fingerprint] = await Promise.all([this.snapshots.capture(filePath), this.manifests.fingerprint(filePath)]);
371
+ this.pending.set(id, {
372
+ tool,
373
+ filePath,
374
+ before,
375
+ existed: fingerprint !== void 0
376
+ });
377
+ return;
378
+ }
379
+ if (tool !== BASH_TOOL) return;
380
+ this.bracketed.set(id, this.now());
381
+ this.manifest ??= await this.manifests.take(cwd, this.excluded);
382
+ }
383
+ async end(id, isError, cwd) {
384
+ const pending = this.pending.get(id);
385
+ this.pending.delete(id);
386
+ const startedAt = this.bracketed.get(id);
387
+ this.bracketed.delete(id);
388
+ if (pending) {
389
+ if (!isError) await this.recordTool(pending);
390
+ return;
391
+ }
392
+ if (startedAt === void 0) return;
393
+ this.scanning = this.scanning.then(async () => {
394
+ try {
395
+ await this.recordScan(cwd, startedAt);
396
+ } catch {}
397
+ });
398
+ }
399
+ /** An `edit` or `write` whose file was read on both sides of the call. */
400
+ async recordTool(pending) {
401
+ const after = await this.snapshots.capture(pending.filePath);
402
+ const created = !pending.existed;
403
+ const counts = await this.countChanges(pending.before, after, created);
404
+ if (after !== void 0) this.contents.set(pending.filePath, after);
405
+ await this.append({
406
+ version: 2,
407
+ path: pending.filePath,
408
+ tool: pending.tool,
409
+ at: this.now(),
410
+ origin: "tool",
411
+ ...pending.before === void 0 ? {} : { before: pending.before },
412
+ ...after === void 0 ? {} : { after },
413
+ ...created ? { created: true } : {},
414
+ ...counts
415
+ });
416
+ await this.remember(pending.filePath);
417
+ }
418
+ /** Whatever a bash call actually edited, however the command wrote it. */
419
+ async recordScan(cwd, startedAt) {
420
+ const before = this.manifest;
421
+ if (before === void 0) return;
422
+ const after = await this.manifests.take(cwd, this.excluded);
423
+ this.manifest = after;
424
+ const disowned = this.isIgnored;
425
+ const named = this.manifests.changed(before, after).filter((filePath) => disowned === void 0 || !disowned(filePath));
426
+ const candidates = await this.writtenDuring(named, startedAt);
427
+ if (candidates.length === 0) return;
428
+ const unchanged = await this.unchangedInGit(cwd, candidates);
429
+ for (const filePath of candidates) {
430
+ const known = this.contents.get(filePath);
431
+ if (known === void 0 && unchanged.has(filePath)) continue;
432
+ const beforePrint = before.entries.get(filePath);
433
+ const afterPrint = after.entries.get(filePath);
434
+ const captured = afterPrint === void 0 ? void 0 : await this.snapshots.capture(filePath);
435
+ if (captured !== void 0) this.contents.set(filePath, captured);
436
+ if (captured !== void 0 && captured === known) continue;
437
+ const proven = beforePrint === void 0 || afterPrint === void 0 || this.manifests.sizeChanged(beforePrint, afterPrint) || known !== void 0 && captured !== void 0 && captured !== known;
438
+ await this.append({
439
+ version: 2,
440
+ path: filePath,
441
+ tool: BASH_TOOL,
442
+ at: this.now(),
443
+ origin: "scan",
444
+ ...proven ? { verified: true } : {},
445
+ ...captured === void 0 ? {} : { after: captured }
446
+ });
447
+ }
448
+ }
449
+ /**
450
+ * Narrows candidates to the ones that could have been written by the call
451
+ * being closed.
452
+ *
453
+ * This is what keeps a working tree that was already dirty out of the list. A
454
+ * file whose bytes were last written before the call began was differed by
455
+ * something else, whatever moved its fingerprint. A file that is no longer
456
+ * there has no time to read and a delete is a change the call may well have
457
+ * made, so it stays a candidate.
458
+ */
459
+ async writtenDuring(candidates, startedAt) {
460
+ const floor = startedAt - MODIFIED_TOLERANCE_MS;
461
+ const written = [];
462
+ for (const filePath of candidates) {
463
+ const modifiedAt = await this.manifests.modifiedAt(filePath);
464
+ if (modifiedAt === void 0 || modifiedAt >= floor) written.push(filePath);
465
+ }
466
+ return written;
467
+ }
468
+ /** Which candidates git tracks and reports as untouched; empty when it cannot say. */
469
+ async unchangedInGit(cwd, candidates) {
470
+ const unseen = candidates.filter((filePath) => !this.contents.has(filePath));
471
+ if (this.git === void 0 || unseen.length === 0) return /* @__PURE__ */ new Set();
472
+ return this.git.unchanged(cwd, unseen);
473
+ }
474
+ /** How many lines moved, when there is enough captured content to say. */
475
+ async countChanges(before, after, created) {
476
+ if (after === void 0) return {};
477
+ if (before === void 0) {
478
+ if (!created) return {};
479
+ const createdText = await this.snapshots.read(after);
480
+ if (createdText === void 0) return {};
481
+ const fresh = lineDiffFromEmpty(createdText);
482
+ return {
483
+ additions: fresh.additions,
484
+ removals: fresh.removals
485
+ };
486
+ }
487
+ if (before === after) return {
488
+ additions: 0,
489
+ removals: 0
490
+ };
491
+ const [beforeText, afterText] = await Promise.all([this.snapshots.read(before), this.snapshots.read(after)]);
492
+ if (beforeText === void 0 || afterText === void 0) return {};
493
+ const diff = lineDiff(beforeText, afterText);
494
+ return {
495
+ additions: diff.additions,
496
+ removals: diff.removals
497
+ };
498
+ }
499
+ /** Records the change unless it turned out to be no change at all. */
500
+ async append(event) {
501
+ if (event.before !== void 0 && event.before === event.after) return;
502
+ await this.timeline.append(event);
503
+ }
504
+ /**
505
+ * Folds a path the tool layer just handled into the tracked manifest, so the
506
+ * next bash comparison sees it as already accounted for.
507
+ *
508
+ * The fingerprint has to be the one a walk would read, not a stand-in, or the
509
+ * next comparison reports the file again under bash and every tool edit is
510
+ * recorded twice.
511
+ */
512
+ async remember(filePath) {
513
+ if (this.manifest === void 0) return;
514
+ const entries = new Map(this.manifest.entries);
515
+ const fingerprint = await this.manifests.fingerprint(filePath);
516
+ if (fingerprint === void 0) entries.delete(filePath);
517
+ else entries.set(filePath, fingerprint);
518
+ this.manifest = {
519
+ entries,
520
+ truncated: this.manifest.truncated
521
+ };
522
+ }
523
+ };
524
+ //#endregion
525
+ //#region src/services/fileEditPaths/index.ts
526
+ /** The directory this package owns, under whichever root holds agent state. */
527
+ const STATE_DIRECTORY = "doom-file-edit";
528
+ function hash(value) {
529
+ return (0, node_crypto.createHash)("sha256").update(value).digest("hex").slice(0, 16);
530
+ }
531
+ var FileEditPaths = class {
532
+ environment;
533
+ constructor(environment = process.env) {
534
+ this.environment = environment;
535
+ }
536
+ sessionKey(sessionId, env = this.environment) {
537
+ if (env[_agimon_ai_po_core_childProcess.SUBAGENT_CHILD_ENV]) {
538
+ const parent = env[_agimon_ai_po_core_childProcess.SUBAGENT_PARENT_SESSION_ENV];
539
+ if (parent) return parent;
540
+ }
541
+ return sessionId;
542
+ }
543
+ /**
544
+ * Where every session's state lives: the Pi agent directory, the same root
545
+ * every other Doom package writes to, and never inside the working tree.
546
+ *
547
+ * An earlier layout put this in the repository's git common directory. That
548
+ * is git's own storage: nothing in git's lifecycle prunes a directory we add
549
+ * to it, a linked worktree writes into the main checkout rather than its own,
550
+ * a submodule writes inside `.git/modules`, and `PI_CODING_AGENT_DIR` was
551
+ * ignored outright. The snapshots are verbatim copies of files the session
552
+ * touched, so where they land is a question about handling content, not a
553
+ * question about convenience.
554
+ */
555
+ stateDirectory() {
556
+ return node_path.default.join((0, _agimon_ai_po_core_piSettings.piAgentDirectory)(this.environment, this.environment.HOME), STATE_DIRECTORY);
557
+ }
558
+ /**
559
+ * One session's timeline, named from its working directory and its session so
560
+ * the hub and the session API can find it from another process knowing only
561
+ * those two things.
562
+ */
563
+ timelinePath(cwd, sessionKey) {
564
+ const directory = this.stateDirectory();
565
+ node_fs.default.mkdirSync(directory, { recursive: true });
566
+ return node_path.default.join(directory, `${hash(node_fs.default.realpathSync(cwd))}-${hash(sessionKey)}.jsonl`);
567
+ }
568
+ /**
569
+ * Where this session's content snapshots live: beside its timeline and named
570
+ * after it, so the two are found together and cleared together.
571
+ */
572
+ snapshotsPath(cwd, sessionKey) {
573
+ return `${this.timelinePath(cwd, sessionKey).replace(/\.jsonl$/u, "")}.blobs`;
574
+ }
575
+ legacyStateDirectory(cwd) {
576
+ const common = this.gitCommonDirectory(cwd);
577
+ return common === void 0 ? void 0 : node_path.default.join(common, STATE_DIRECTORY);
578
+ }
579
+ /** Only still read so the state an older build left behind can be cleared away. */
580
+ gitCommonDirectory(cwd) {
581
+ try {
582
+ return (0, node_child_process.execFileSync)("git", [
583
+ "rev-parse",
584
+ "--path-format=absolute",
585
+ "--git-common-dir"
586
+ ], {
587
+ cwd,
588
+ encoding: "utf8",
589
+ stdio: [
590
+ "ignore",
591
+ "pipe",
592
+ "ignore"
593
+ ]
594
+ }).trim() || void 0;
595
+ } catch (error) {
596
+ if (error instanceof Error) return void 0;
597
+ throw error;
598
+ }
599
+ }
600
+ };
601
+ //#endregion
602
+ //#region src/services/gitStatus/index.ts
603
+ /**
604
+ * Answers the unchanged question with git, in two batched calls per chunk.
605
+ *
606
+ * One technology per adapter. This is where git enters the scan path; the
607
+ * tracker that depends on the capability keeps importing the port.
608
+ *
609
+ * `ls-files` names which of the given paths git tracks at all, and `status`
610
+ * names which of those moved. Everything else, an ignored build artefact, a
611
+ * temporary file, a tree with no repository, falls out of both answers and is
612
+ * therefore never reported as unchanged: the caller must keep recording it,
613
+ * because git has nothing to say about a file it never saw.
614
+ */
615
+ /** Paths per git invocation, so a large scan cannot overflow the argument list. */
616
+ const CHUNK = 200;
617
+ const MAX_BUFFER = 4194304;
618
+ const run = (0, node_util.promisify)(node_child_process.execFile);
619
+ function chunked(values, size) {
620
+ const groups = [];
621
+ for (let index = 0; index < values.length; index += size) groups.push(values.slice(index, index + size));
622
+ return groups;
623
+ }
624
+ function splitRecords(output) {
625
+ return output.split("\0").filter((entry) => entry !== "");
626
+ }
627
+ /**
628
+ * The paths named by `status --porcelain -z`, whose records carry a two-letter
629
+ * code and a space. A rename adds a bare second record; keeping it is harmless,
630
+ * because every path in this set is one the caller records anyway.
631
+ */
632
+ function statusPaths(output) {
633
+ return splitRecords(output).map((record) => record.length > 3 && record[2] === " " ? record.slice(3) : record);
634
+ }
635
+ function isInside$1(root, filePath) {
636
+ const relative = node_path.default.relative(root, filePath);
637
+ return relative !== "" && !relative.startsWith("..") && !node_path.default.isAbsolute(relative);
638
+ }
639
+ var NodeGitStatusAdapter = class {
640
+ async unchanged(cwd, filePaths) {
641
+ const clean = /* @__PURE__ */ new Set();
642
+ if (filePaths.length === 0) return clean;
643
+ const root = await this.topLevel(cwd);
644
+ if (root === void 0) return clean;
645
+ for (const group of chunked(filePaths.filter((filePath) => isInside$1(root, filePath)), CHUNK)) {
646
+ const relative = group.map((filePath) => node_path.default.relative(root, filePath));
647
+ const tracked = await this.git(root, [
648
+ "ls-files",
649
+ "-z",
650
+ "--",
651
+ ...relative
652
+ ]);
653
+ if (tracked === void 0) continue;
654
+ const dirty = await this.git(root, [
655
+ "status",
656
+ "--porcelain",
657
+ "-z",
658
+ "--untracked-files=no",
659
+ "--",
660
+ ...relative
661
+ ]);
662
+ if (dirty === void 0) continue;
663
+ const moved = new Set(statusPaths(dirty));
664
+ for (const entry of splitRecords(tracked)) if (!moved.has(entry)) clean.add(node_path.default.resolve(root, entry));
665
+ }
666
+ return clean;
667
+ }
668
+ /** The repository root, or undefined when this tree is not one. */
669
+ async topLevel(cwd) {
670
+ const root = (await this.git(cwd, ["rev-parse", "--show-toplevel"]))?.trim();
671
+ return root === void 0 || root === "" ? void 0 : node_path.default.resolve(root);
672
+ }
673
+ /** Git's stdout, or undefined when the call failed for any reason at all. */
674
+ async git(cwd, args) {
675
+ try {
676
+ const { stdout } = await run("git", [...args], {
677
+ cwd,
678
+ encoding: "utf8",
679
+ maxBuffer: MAX_BUFFER
680
+ });
681
+ return stdout;
682
+ } catch {
683
+ return;
684
+ }
685
+ }
686
+ };
687
+ /** How much of a file is read before deciding it is binary. */
688
+ const BINARY_SAMPLE_BYTES$1 = 8192;
689
+ const MISSING_CODES = /* @__PURE__ */ new Set([
690
+ "ENOENT",
691
+ "ENOTDIR",
692
+ "EISDIR",
693
+ "ENAMETOOLONG"
694
+ ]);
695
+ function hasCode$2(error, codes) {
696
+ return error instanceof Error && "code" in error && typeof error.code === "string" && codes.has(error.code);
697
+ }
698
+ var NodeSnapshotStoreAdapter = class {
699
+ directory;
700
+ initialize(directory) {
701
+ this.directory = directory;
702
+ }
703
+ async capture(filePath) {
704
+ let raw;
705
+ try {
706
+ raw = await node_fs_promises.default.readFile(filePath);
707
+ } catch (error) {
708
+ if (hasCode$2(error, MISSING_CODES)) return void 0;
709
+ throw error;
710
+ }
711
+ if (raw.byteLength > 1048576) return void 0;
712
+ if (raw.subarray(0, BINARY_SAMPLE_BYTES$1).includes(0)) return void 0;
713
+ return this.put(raw.toString("utf8"));
714
+ }
715
+ async put(content) {
716
+ const hash = (0, node_crypto.createHash)("sha256").update(content, "utf8").digest("hex");
717
+ const blobPath = node_path.default.join(this.requireDirectory(), hash);
718
+ try {
719
+ await node_fs_promises.default.access(blobPath);
720
+ return hash;
721
+ } catch (error) {
722
+ if (!hasCode$2(error, MISSING_CODES)) throw error;
723
+ }
724
+ await node_fs_promises.default.mkdir(this.requireDirectory(), { recursive: true });
725
+ await node_fs_promises.default.writeFile(blobPath, content, "utf8");
726
+ return hash;
727
+ }
728
+ async read(hash) {
729
+ if (!/^[0-9a-f]{64}$/u.test(hash)) return void 0;
730
+ try {
731
+ return await node_fs_promises.default.readFile(node_path.default.join(this.requireDirectory(), hash), "utf8");
732
+ } catch (error) {
733
+ if (hasCode$2(error, MISSING_CODES)) return void 0;
734
+ throw error;
735
+ }
736
+ }
737
+ async clear() {
738
+ if (this.directory === void 0) return;
739
+ await node_fs_promises.default.rm(this.directory, {
740
+ recursive: true,
741
+ force: true
742
+ });
743
+ }
744
+ requireDirectory() {
745
+ if (this.directory === void 0) throw new Error("Snapshot store is not initialized");
746
+ return this.directory;
747
+ }
748
+ };
749
+ //#endregion
750
+ //#region src/services/fileChanges/index.ts
751
+ const TOOLS = /* @__PURE__ */ new Set([
752
+ "edit",
753
+ "write",
754
+ "bash",
755
+ "user"
756
+ ]);
757
+ function isRecord(value) {
758
+ return typeof value === "object" && value !== null && !Array.isArray(value);
759
+ }
760
+ function optionalString(value) {
761
+ return typeof value === "string" && value !== "" ? value : void 0;
762
+ }
763
+ function optionalCount(value) {
764
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
765
+ }
766
+ /** Narrows one parsed line, or answers null so a malformed line can be skipped. */
767
+ function parseTimelineEvent(value) {
768
+ if (!isRecord(value)) return null;
769
+ const { version, path: filePath, tool, at } = value;
770
+ if (typeof filePath !== "string" || filePath === "") return null;
771
+ if (typeof at !== "number" || !Number.isFinite(at)) return null;
772
+ if (typeof tool !== "string" || !TOOLS.has(tool)) return null;
773
+ if (version === 1) {
774
+ if (tool === "user") return null;
775
+ return {
776
+ version: 1,
777
+ path: filePath,
778
+ tool,
779
+ at
780
+ };
781
+ }
782
+ if (version !== 2) return null;
783
+ return {
784
+ version: 2,
785
+ path: filePath,
786
+ tool,
787
+ at,
788
+ origin: value.origin === "scan" ? "scan" : "tool",
789
+ ...optionalString(value.before) === void 0 ? {} : { before: value.before },
790
+ ...optionalString(value.after) === void 0 ? {} : { after: value.after },
791
+ ...optionalCount(value.additions) === void 0 ? {} : { additions: value.additions },
792
+ ...optionalCount(value.removals) === void 0 ? {} : { removals: value.removals },
793
+ ...value.verified === true ? { verified: true } : {},
794
+ ...value.created === true ? { created: true } : {}
795
+ };
796
+ }
797
+ /** Every recorded line of the timeline, malformed ones dropped, in file order. */
798
+ function parseTimeline(content, onMalformed) {
799
+ const events = [];
800
+ for (const line of content.split("\n")) {
801
+ if (line === "") continue;
802
+ let parsed;
803
+ try {
804
+ parsed = JSON.parse(line);
805
+ } catch {
806
+ onMalformed?.(line);
807
+ continue;
808
+ }
809
+ const event = parseTimelineEvent(parsed);
810
+ if (event === null) onMalformed?.(line);
811
+ else events.push(event);
812
+ }
813
+ return events;
814
+ }
815
+ /**
816
+ * Whether a recorded change is evidence that the file's content actually moved.
817
+ *
818
+ * A tool change names its file and reads both sides, so it always is. A scan
819
+ * change is a tree comparison of size and modification time, which a checkout,
820
+ * an install, or a command that merely touched a file moves as readily as an
821
+ * edit does; only a scan the tracker confirmed carries `verified`. Version 1
822
+ * lines predate the distinction entirely, so a bash one cannot be trusted.
823
+ */
824
+ function isConfirmedChange(event) {
825
+ if (event.version !== 2) return event.tool !== "bash";
826
+ return event.origin !== "scan" || event.verified === true;
827
+ }
828
+ /** The recorded changes worth listing, which is every change but an unconfirmed touch. */
829
+ function confirmedChanges(events) {
830
+ return events.filter(isConfirmedChange);
831
+ }
832
+ /** One row per file, newest change first, which is the order both docks list in. */
833
+ function foldEntries(events) {
834
+ const folded = /* @__PURE__ */ new Map();
835
+ for (const event of events) {
836
+ const current = folded.get(event.path);
837
+ folded.set(event.path, {
838
+ path: event.path,
839
+ tool: event.at >= (current?.at ?? 0) ? event.tool : current?.tool ?? event.tool,
840
+ at: Math.max(event.at, current?.at ?? 0),
841
+ count: (current?.count ?? 0) + 1
842
+ });
843
+ }
844
+ return [...folded.values()].sort((left, right) => right.at - left.at);
845
+ }
846
+ /** One file's history, oldest first, numbered so a reader can name a version. */
847
+ function foldVersions(events, filePath) {
848
+ return events.filter((event) => event.path === filePath).sort((left, right) => left.at - right.at).map((event, offset) => {
849
+ const version = {
850
+ index: offset + 1,
851
+ tool: event.tool,
852
+ at: event.at,
853
+ origin: event.version === 2 ? event.origin : "scan"
854
+ };
855
+ if (event.version !== 2) return version;
856
+ return {
857
+ ...version,
858
+ ...event.before === void 0 ? {} : { before: event.before },
859
+ ...event.after === void 0 ? {} : { after: event.after },
860
+ ...event.additions === void 0 ? {} : { additions: event.additions },
861
+ ...event.removals === void 0 ? {} : { removals: event.removals },
862
+ ...event.verified === true ? { verified: true } : {},
863
+ ...event.created === true ? { created: true } : {}
864
+ };
865
+ });
866
+ }
867
+ /**
868
+ * Whether a file can be diffed at all: some version has to give the diff a
869
+ * starting point. A captured baseline is one. So is a version that created the
870
+ * file, whose starting point is nothing at all.
871
+ *
872
+ * A file only ever seen after the fact, which is every file a bash script
873
+ * wrote, still cannot be.
874
+ */
875
+ function isDiffable(versions) {
876
+ return versions.some((version) => version.before !== void 0 || version.created === true);
877
+ }
878
+ /** The oldest captured baseline for a file, which is what the whole-session diff starts from. */
879
+ function baselineOf(versions) {
880
+ return versions.find((version) => version.before !== void 0)?.before;
881
+ }
882
+ //#endregion
883
+ //#region src/services/timelineStore/index.ts
884
+ const LOCK_RETRY_MS = 25;
885
+ const LOCK_RETRIES = 400;
886
+ /**
887
+ * How long a lock file may sit untouched before a waiter treats it as abandoned.
888
+ *
889
+ * An append writes one line, so a holder that has been there for half a minute
890
+ * is a process that died without unlinking. Without this, that one file would
891
+ * block every later append to the same timeline permanently.
892
+ */
893
+ const LOCK_STALE_MS = 3e4;
894
+ function hasCode$1(error, code) {
895
+ return error instanceof Error && "code" in error && error.code === code;
896
+ }
897
+ var TimelineStore = class {
898
+ filePath;
899
+ initialize(filePath) {
900
+ this.filePath = filePath;
901
+ }
902
+ async append(event) {
903
+ await this.withLock(async () => node_fs_promises.default.appendFile(this.requirePath(), `${JSON.stringify(event)}\n`, "utf8"));
904
+ }
905
+ async list() {
906
+ return foldEntries(confirmedChanges(await this.events()));
907
+ }
908
+ async versions(filePath) {
909
+ return foldVersions(confirmedChanges(await this.events()), filePath);
910
+ }
911
+ async clear() {
912
+ try {
913
+ await node_fs_promises.default.unlink(this.requirePath());
914
+ } catch (error) {
915
+ if (!hasCode$1(error, "ENOENT")) throw error;
916
+ }
917
+ }
918
+ /** Every recorded change, or none at all when the session has not written yet. */
919
+ async events() {
920
+ let content;
921
+ try {
922
+ content = await node_fs_promises.default.readFile(this.requirePath(), "utf8");
923
+ } catch (error) {
924
+ if (hasCode$1(error, "ENOENT")) return [];
925
+ throw error;
926
+ }
927
+ return parseTimeline(content, (line) => console.warn(`Ignoring malformed file edit timeline line: ${line.slice(0, 200)}`));
928
+ }
929
+ requirePath() {
930
+ if (!this.filePath) throw new Error("Timeline store is not initialized");
931
+ return this.filePath;
932
+ }
933
+ async withLock(operation) {
934
+ const lockPath = `${this.requirePath()}.lock`;
935
+ const lock = await this.acquire(lockPath);
936
+ try {
937
+ return await operation();
938
+ } finally {
939
+ await lock.close();
940
+ await this.release(lockPath);
941
+ }
942
+ }
943
+ /** Waits for the lock file, breaking one an earlier process left behind. */
944
+ async acquire(lockPath) {
945
+ for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) try {
946
+ return await node_fs_promises.default.open(lockPath, "wx");
947
+ } catch (error) {
948
+ if (!hasCode$1(error, "EEXIST")) throw error;
949
+ if (await this.breakStale(lockPath)) continue;
950
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
951
+ }
952
+ throw new Error(`Timed out acquiring timeline lock ${lockPath}`);
953
+ }
954
+ /** Whether the lock is now free to take, having removed it when its holder is gone. */
955
+ async breakStale(lockPath) {
956
+ try {
957
+ const stats = await node_fs_promises.default.stat(lockPath);
958
+ if (Date.now() - stats.mtimeMs < LOCK_STALE_MS) return false;
959
+ await node_fs_promises.default.unlink(lockPath);
960
+ return true;
961
+ } catch (error) {
962
+ if (hasCode$1(error, "ENOENT")) return true;
963
+ throw error;
964
+ }
965
+ }
966
+ async release(lockPath) {
967
+ try {
968
+ await node_fs_promises.default.unlink(lockPath);
969
+ } catch (error) {
970
+ if (!hasCode$1(error, "ENOENT")) console.warn(`Could not remove timeline lock: ${String(error)}`);
971
+ }
972
+ }
973
+ };
974
+ //#endregion
975
+ //#region src/services/treeManifest/index.ts
976
+ /**
977
+ * Take a bounded manifest of a working tree and report which files moved
978
+ * between two of them.
979
+ *
980
+ * One technology per adapter. This is where node enters the package; the
981
+ * services that depend on the capability keep importing the port.
982
+ *
983
+ * The caps are the whole design. An unbounded walk of a working directory is
984
+ * dominated by dependency and build trees, which change constantly and tell a
985
+ * reader nothing, so those are skipped by name; what remains is capped on both
986
+ * entries and depth so the walk stays a fixed cost rather than a function of
987
+ * how large the checkout grew. Hitting a cap is reported, never hidden.
988
+ */
989
+ /**
990
+ * Directories a working tree fills with output nobody wants listed as an edit.
991
+ *
992
+ * Two kinds live here. Most are the build and dependency trees every checkout
993
+ * grows. The rest are where tooling, including this agent's own, writes while a
994
+ * session runs: a telemetry log under `logs`, a test run's artifacts under
995
+ * `test-results` and `playwright-report`, scratch space under `tmp`, and agent
996
+ * state under `.pi`. Those are skipped by construction rather than judged file
997
+ * by file, because a walk that enters them reports the session watching itself.
998
+ */
999
+ const IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([
1000
+ ".git",
1001
+ ".hg",
1002
+ ".svn",
1003
+ ".cache",
1004
+ ".gradle",
1005
+ ".next",
1006
+ ".nuxt",
1007
+ ".nx",
1008
+ ".pi",
1009
+ ".pnpm-store",
1010
+ ".turbo",
1011
+ ".venv",
1012
+ "__pycache__",
1013
+ "build",
1014
+ "coverage",
1015
+ "dist",
1016
+ "logs",
1017
+ "node_modules",
1018
+ "out",
1019
+ "playwright-report",
1020
+ "target",
1021
+ "test-results",
1022
+ "tmp",
1023
+ "vendor",
1024
+ "venv"
1025
+ ]);
1026
+ /**
1027
+ * How a file is summarised. Size and modification time are what a walk can read
1028
+ * cheaply for thousands of files; hashing every one of them would turn a scan
1029
+ * into a read of the whole tree.
1030
+ */
1031
+ function fingerprintOf(size, modifiedMs) {
1032
+ return `${size}:${modifiedMs}`;
1033
+ }
1034
+ /** The size half of a fingerprint, which only the adapter that wrote it can read. */
1035
+ function sizeOf(fingerprint) {
1036
+ const separator = fingerprint.indexOf(":");
1037
+ return separator === -1 ? fingerprint : fingerprint.slice(0, separator);
1038
+ }
1039
+ var NodeTreeManifestAdapter = class {
1040
+ maxEntries;
1041
+ maxDepth;
1042
+ ignored;
1043
+ constructor(options = {}) {
1044
+ this.maxEntries = options.maxEntries ?? 2e4;
1045
+ this.maxDepth = options.maxDepth ?? 12;
1046
+ this.ignored = options.ignoredDirectories ?? IGNORED_DIRECTORIES;
1047
+ }
1048
+ async take(root, exclude = []) {
1049
+ const entries = /* @__PURE__ */ new Map();
1050
+ const skipped = new Set(exclude.map((entry) => node_path.default.resolve(entry)));
1051
+ return {
1052
+ entries,
1053
+ truncated: await this.walk(root, 0, entries, skipped)
1054
+ };
1055
+ }
1056
+ async fingerprint(filePath) {
1057
+ try {
1058
+ const stat = await node_fs_promises.default.stat(filePath);
1059
+ return stat.isFile() ? fingerprintOf(stat.size, stat.mtimeMs) : void 0;
1060
+ } catch {
1061
+ return;
1062
+ }
1063
+ }
1064
+ async modifiedAt(filePath) {
1065
+ try {
1066
+ const stat = await node_fs_promises.default.stat(filePath);
1067
+ return stat.isFile() ? stat.mtimeMs : void 0;
1068
+ } catch {
1069
+ return;
1070
+ }
1071
+ }
1072
+ changed(before, after) {
1073
+ const moved = /* @__PURE__ */ new Set();
1074
+ for (const [filePath, fingerprint] of after.entries) if (before.entries.get(filePath) !== fingerprint) moved.add(filePath);
1075
+ for (const filePath of before.entries.keys()) if (!after.entries.has(filePath)) moved.add(filePath);
1076
+ return [...moved].sort();
1077
+ }
1078
+ sizeChanged(before, after) {
1079
+ if (before === void 0 || after === void 0) return false;
1080
+ return sizeOf(before) !== sizeOf(after);
1081
+ }
1082
+ /** Fills `entries` depth-first and answers whether a cap cut the walk short. */
1083
+ async walk(directory, depth, entries, skipped) {
1084
+ if (depth > this.maxDepth) return true;
1085
+ let listing;
1086
+ try {
1087
+ listing = await node_fs_promises.default.readdir(directory, { withFileTypes: true });
1088
+ } catch {
1089
+ return false;
1090
+ }
1091
+ let truncated = false;
1092
+ for (const entry of listing) {
1093
+ if (entries.size >= this.maxEntries) return true;
1094
+ const entryPath = node_path.default.join(directory, entry.name);
1095
+ if (skipped.has(entryPath)) continue;
1096
+ if (entry.isSymbolicLink()) continue;
1097
+ if (entry.isDirectory()) {
1098
+ if (this.ignored.has(entry.name)) continue;
1099
+ truncated = await this.walk(entryPath, depth + 1, entries, skipped) || truncated;
1100
+ continue;
1101
+ }
1102
+ if (!entry.isFile()) continue;
1103
+ try {
1104
+ const stat = await node_fs_promises.default.stat(entryPath);
1105
+ entries.set(entryPath, fingerprintOf(stat.size, stat.mtimeMs));
1106
+ } catch {}
1107
+ }
1108
+ return truncated;
1109
+ }
1110
+ };
1111
+ //#endregion
1112
+ //#region src/types/webFiles.ts
1113
+ /** The frame type the hub publishes and the web plugin claims; declared in the poWeb block too. */
1114
+ const filesChannelType = "file_edits";
1115
+ /** Footer status key whose presence shows the activity group; the extension publishes it. */
1116
+ const filesStatusKey = "doom-file-edit-files";
1117
+ //#endregion
1118
+ //#region src/types/apiRoutes.ts
1119
+ /**
1120
+ * This package's routes, as data.
1121
+ *
1122
+ * No scope and no base path. The build reads both off
1123
+ * `src/extensions/workspaces/sessions/(backend)/api/file-edits/`, so the mount
1124
+ * is stated once, by the folder that creates it. They used to be written here,
1125
+ * in the contract and again inside every URL the page built, and the copies
1126
+ * were free to disagree.
1127
+ *
1128
+ * This is the one file the routes, the Hono app and the browser all read, so a
1129
+ * path cannot move on one side alone.
1130
+ */
1131
+ var apiRoutes_default = (0, _agimon_ai_po_core_web.defineApiRoutes)({
1132
+ detail: {
1133
+ method: "GET",
1134
+ path: "/detail",
1135
+ query: ["path"],
1136
+ response: (0, _agimon_ai_po_core_web.apiResponse)()
1137
+ },
1138
+ preview: {
1139
+ method: "GET",
1140
+ path: "/preview",
1141
+ query: ["path"],
1142
+ response: (0, _agimon_ai_po_core_web.apiResponse)()
1143
+ },
1144
+ save: {
1145
+ method: "PUT",
1146
+ path: "/content",
1147
+ response: (0, _agimon_ai_po_core_web.apiResponse)()
1148
+ },
1149
+ /** The same path as `save`; the method is the whole of the difference. */
1150
+ remove: {
1151
+ method: "DELETE",
1152
+ path: "/content",
1153
+ query: ["path"]
1154
+ },
1155
+ /**
1156
+ * The host's own bytes route, not this package's.
1157
+ *
1158
+ * `host: true` hangs it off the session root rather than under `/plugins`,
1159
+ * which is how the cockpit already serves it. Declaring it here is the first
1160
+ * time anything records that this package's previews depend on it.
1161
+ */
1162
+ file: {
1163
+ method: "GET",
1164
+ path: "/file",
1165
+ query: ["path"],
1166
+ host: true
1167
+ }
1168
+ });
1169
+ //#endregion
1170
+ //#region src/types/fileEditsApi.ts
1171
+ /**
1172
+ * The wire vocabulary this package's API shares with whatever calls it.
1173
+ *
1174
+ * It lives under src/types because that is the one server root a browser
1175
+ * bundle may read: the cockpit plugin builds its URLs from these and the
1176
+ * routes answer them, so neither half can drift from the other.
1177
+ *
1178
+ * Routes live in `./apiRoutes`, which the contract, the Hono app and the
1179
+ * generated client all read. This file holds the shapes that travel over them.
1180
+ *
1181
+ * Opening a changed file wants its whole history at once, so `detail` answers
1182
+ * in one round trip rather than making the page stitch three reads together;
1183
+ * `content` takes the manual save back; `preview` reads a file the session
1184
+ * never changed, which has no history to answer with.
1185
+ */
1186
+ /** Where a host mounts this package's API; the segment after /api/plugins/. */
1187
+ const API_BASE_PATH = "file-edits";
1188
+ /** Query parameter naming the file a request is about. */
1189
+ const PATH_QUERY_PARAM = "path";
1190
+ //#endregion
1191
+ //#region src/services/fileEditsApi/index.ts
1192
+ /**
1193
+ * This package's HTTP surface: one file's history, and the manual save.
1194
+ *
1195
+ * Routes are relative to the mount its host gives it, so nothing here repeats
1196
+ * where it was mounted. The host is one session's own server, which is what
1197
+ * makes the session id and working directory available without the page
1198
+ * supplying either: a page names a file, never a session's private paths.
1199
+ *
1200
+ * The detail route answers everything a reader needs to open a changed file,
1201
+ * because splitting it would make the page stitch three responses together for
1202
+ * one click. The save route is the only mutation, and it refuses unless the
1203
+ * reader proves they were looking at the content that is still on disk. The
1204
+ * preview route is the read-only way in to a file the session never changed,
1205
+ * bounded by the working directory instead of by the timeline.
1206
+ */
1207
+ /** Past this, the working view reports the file rather than carrying it. */
1208
+ const MAX_WORKING_BYTES = 1048576;
1209
+ const BINARY_SAMPLE_BYTES = 8192;
1210
+ const NO_BASELINE_NOTE = "changed by a command, so no baseline was captured; showing the file as it stands";
1211
+ /**
1212
+ * A change the tool layer named but never captured content for. That is what
1213
+ * every record written before this package learned to snapshot looks like, and
1214
+ * calling those "changed by a command" would be wrong: the tool is right there
1215
+ * in the record.
1216
+ */
1217
+ const NO_SNAPSHOT_NOTE = "recorded before this session began capturing content, so there is no diff for it";
1218
+ const NO_AFTER_NOTE = "the new content could not be captured: the file is binary or past the size cap";
1219
+ const NO_BEFORE_NOTE = "the previous content could not be captured: it was binary or past the size cap";
1220
+ const APPROXIMATE_NOTE = "the changed region was too large to match line by line";
1221
+ function hashOf(content) {
1222
+ return (0, node_crypto.createHash)("sha256").update(content, "utf8").digest("hex");
1223
+ }
1224
+ function hasCode(error, code) {
1225
+ return error instanceof Error && "code" in error && error.code === code;
1226
+ }
1227
+ function describe(error) {
1228
+ return error instanceof Error ? error.message : String(error);
1229
+ }
1230
+ /** Reads the file as the source view will show it, or says why it cannot. */
1231
+ async function readWorking(filePath) {
1232
+ let raw;
1233
+ try {
1234
+ raw = await node_fs_promises.default.readFile(filePath);
1235
+ } catch {
1236
+ return {
1237
+ content: "",
1238
+ hash: "",
1239
+ unavailable: true,
1240
+ reason: "the file no longer exists"
1241
+ };
1242
+ }
1243
+ if (raw.byteLength > 1048576) return {
1244
+ content: "",
1245
+ hash: "",
1246
+ unavailable: true,
1247
+ reason: `the file is larger than ${MAX_WORKING_BYTES} bytes`
1248
+ };
1249
+ if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) return {
1250
+ content: "",
1251
+ hash: "",
1252
+ unavailable: true,
1253
+ reason: "the file is binary"
1254
+ };
1255
+ const content = raw.toString("utf8");
1256
+ return {
1257
+ content,
1258
+ hash: hashOf(content),
1259
+ unavailable: false
1260
+ };
1261
+ }
1262
+ function isInside(root, candidate) {
1263
+ return candidate === root || candidate.startsWith(`${root}${node_path.default.sep}`);
1264
+ }
1265
+ /**
1266
+ * The same containment check after symlinks are followed. A path that does not
1267
+ * exist yet resolves to nothing, and there is nothing to hand out, so it
1268
+ * passes and the read below reports it missing.
1269
+ */
1270
+ async function isRealPathInside(root, candidate) {
1271
+ let realRoot;
1272
+ let real;
1273
+ try {
1274
+ realRoot = await node_fs_promises.default.realpath(root);
1275
+ real = await node_fs_promises.default.realpath(candidate);
1276
+ } catch (error) {
1277
+ return hasCode(error, "ENOENT");
1278
+ }
1279
+ return isInside(realRoot, real);
1280
+ }
1281
+ /**
1282
+ * One recorded change as the page renders it. A change with both sides captured
1283
+ * carries its own diff; one without says why, rather than showing nothing.
1284
+ *
1285
+ * A file this session created counts as captured on both sides. Its baseline is
1286
+ * empty rather than missing, so the whole file reads as arriving at once, which
1287
+ * is exactly what happened.
1288
+ */
1289
+ async function toVersionView(version, snapshots) {
1290
+ const view = {
1291
+ index: version.index,
1292
+ tool: version.tool,
1293
+ at: version.at,
1294
+ origin: version.origin,
1295
+ additions: version.additions ?? 0,
1296
+ removals: version.removals ?? 0
1297
+ };
1298
+ const createdHere = version.before === void 0 && version.created === true;
1299
+ if (version.after === void 0 || version.before === void 0 && !createdHere) {
1300
+ if (version.origin === "tool") return {
1301
+ ...view,
1302
+ note: version.after === void 0 ? NO_AFTER_NOTE : NO_BEFORE_NOTE
1303
+ };
1304
+ return {
1305
+ ...view,
1306
+ note: version.tool === "bash" ? NO_BASELINE_NOTE : NO_SNAPSHOT_NOTE
1307
+ };
1308
+ }
1309
+ const after = await snapshots.read(version.after);
1310
+ if (after === void 0) return {
1311
+ ...view,
1312
+ note: "the content behind this change is no longer held"
1313
+ };
1314
+ if (version.before === void 0) {
1315
+ const fresh = lineDiffFromEmpty(after);
1316
+ return {
1317
+ ...view,
1318
+ additions: fresh.additions,
1319
+ removals: fresh.removals,
1320
+ hunks: fresh.hunks,
1321
+ ...fresh.approximate ? { note: APPROXIMATE_NOTE } : {}
1322
+ };
1323
+ }
1324
+ const before = await snapshots.read(version.before);
1325
+ if (before === void 0) return {
1326
+ ...view,
1327
+ note: "the content behind this change is no longer held"
1328
+ };
1329
+ const diff = lineDiff(before, after);
1330
+ return {
1331
+ ...view,
1332
+ additions: diff.additions,
1333
+ removals: diff.removals,
1334
+ hunks: diff.hunks,
1335
+ ...diff.approximate ? { note: APPROXIMATE_NOTE } : {}
1336
+ };
1337
+ }
1338
+ /** Everything the session did to the file: its oldest baseline against what is on disk now. */
1339
+ async function toCumulativeView(versions, working, snapshots) {
1340
+ const createdHere = versions[0]?.created === true;
1341
+ const baseline = baselineOf(versions);
1342
+ if (!createdHere && baseline === void 0) return {
1343
+ additions: 0,
1344
+ removals: 0,
1345
+ note: versions[0]?.origin === "tool" ? NO_BEFORE_NOTE : NO_BASELINE_NOTE
1346
+ };
1347
+ if (working.unavailable) return {
1348
+ additions: 0,
1349
+ removals: 0,
1350
+ note: working.reason ?? "the file cannot be read"
1351
+ };
1352
+ if (createdHere || baseline === void 0) {
1353
+ const fresh = lineDiffFromEmpty(working.content);
1354
+ return {
1355
+ additions: fresh.additions,
1356
+ removals: fresh.removals,
1357
+ hunks: fresh.hunks,
1358
+ ...fresh.approximate ? { note: APPROXIMATE_NOTE } : {}
1359
+ };
1360
+ }
1361
+ const before = await snapshots.read(baseline);
1362
+ if (before === void 0) return {
1363
+ additions: 0,
1364
+ removals: 0,
1365
+ note: "the content this session started from is no longer held"
1366
+ };
1367
+ const diff = lineDiff(before, working.content);
1368
+ return {
1369
+ additions: diff.additions,
1370
+ removals: diff.removals,
1371
+ hunks: diff.hunks,
1372
+ ...diff.approximate ? { note: APPROXIMATE_NOTE } : {}
1373
+ };
1374
+ }
1375
+ function createFileEditsApi(options = {}) {
1376
+ const timeline = options.timeline ?? new TimelineStore();
1377
+ const snapshots = options.snapshots ?? new NodeSnapshotStoreAdapter();
1378
+ const paths = options.paths ?? new FileEditPaths();
1379
+ const cwd = options.cwd ?? "";
1380
+ const app = new hono.Hono();
1381
+ if (options.sessionId !== void 0 && cwd !== "") {
1382
+ const sessionKey = paths.sessionKey(options.sessionId);
1383
+ if (options.timeline === void 0) timeline.initialize(paths.timelinePath(cwd, sessionKey));
1384
+ if (options.snapshots === void 0) snapshots.initialize(paths.snapshotsPath(cwd, sessionKey));
1385
+ }
1386
+ /**
1387
+ * The file a request names, rejected unless this session actually recorded a
1388
+ * change to it. That check is the authorization: these routes read and write
1389
+ * whatever path they are handed, so the timeline is what bounds them to files
1390
+ * the session already touched.
1391
+ */
1392
+ const recordedVersions = async (filePath) => {
1393
+ if (filePath === void 0 || filePath === "") return void 0;
1394
+ const versions = await timeline.versions(node_path.default.resolve(cwd, filePath));
1395
+ return versions.length === 0 ? void 0 : versions;
1396
+ };
1397
+ const isInsideSession = async (filePath) => {
1398
+ if (cwd === "") return false;
1399
+ const root = node_path.default.resolve(cwd);
1400
+ return isInside(root, filePath) && await isRealPathInside(root, filePath);
1401
+ };
1402
+ app.get(apiRoutes_default.detail.path, async (context) => {
1403
+ const requested = context.req.query(PATH_QUERY_PARAM);
1404
+ const versions = await recordedVersions(requested);
1405
+ if (versions === void 0 || requested === void 0) return context.json({ error: "This session recorded no changes to that file." }, 404);
1406
+ const filePath = node_path.default.resolve(cwd, requested);
1407
+ const working = await readWorking(filePath);
1408
+ const body = {
1409
+ path: filePath,
1410
+ relPath: cwd === "" ? filePath : node_path.default.relative(cwd, filePath),
1411
+ versions: await Promise.all(versions.map((version) => toVersionView(version, snapshots))),
1412
+ cumulative: await toCumulativeView(versions, working, snapshots),
1413
+ working
1414
+ };
1415
+ return context.json(body);
1416
+ });
1417
+ /**
1418
+ * The file a request names, read only, for a file the timeline knows nothing
1419
+ * about: a path the agent read but never changed is still a path the reader
1420
+ * clicked, and refusing it would leave the link dead.
1421
+ *
1422
+ * The working directory is the boundary here, since the timeline is not.
1423
+ * Containment is checked lexically and again on the real path, so a symlink
1424
+ * inside the tree cannot hand out something beyond it, and a host that gave
1425
+ * these routes no working directory has no boundary to enforce and so serves
1426
+ * nothing.
1427
+ */
1428
+ app.get(apiRoutes_default.preview.path, async (context) => {
1429
+ const requested = context.req.query(PATH_QUERY_PARAM);
1430
+ if (requested === void 0 || requested === "" || requested.includes("\0")) return context.json({ error: "A preview names a path." }, 400);
1431
+ if (cwd === "") return context.json({ error: "This session has no working directory to read from." }, 403);
1432
+ const root = node_path.default.resolve(cwd);
1433
+ const filePath = node_path.default.resolve(root, requested);
1434
+ if (!await isInsideSession(filePath)) return context.json({ error: "The path leaves the session directory." }, 403);
1435
+ const body = {
1436
+ path: filePath,
1437
+ relPath: node_path.default.relative(root, filePath),
1438
+ working: await readWorking(filePath)
1439
+ };
1440
+ return context.json(body);
1441
+ });
1442
+ app.put(apiRoutes_default.save.path, async (context) => {
1443
+ let request;
1444
+ try {
1445
+ request = await context.req.json();
1446
+ } catch {
1447
+ return context.json({ error: "The save body is not JSON." }, 400);
1448
+ }
1449
+ if (typeof request.path !== "string" || typeof request.content !== "string") return context.json({ error: "A save names a path and the content to write." }, 400);
1450
+ if (await recordedVersions(request.path) === void 0) return context.json({ error: "This session recorded no changes to that file." }, 404);
1451
+ const filePath = node_path.default.resolve(cwd, request.path);
1452
+ if (!await isInsideSession(filePath)) return context.json({ error: "Files outside the session directory are read-only." }, 403);
1453
+ const current = await readWorking(filePath);
1454
+ if (current.unavailable) return context.json({ error: current.reason ?? "The file cannot be read." }, 409);
1455
+ if (current.hash !== request.expectedHash) return context.json({
1456
+ error: "The file changed since it was opened.",
1457
+ hash: current.hash
1458
+ }, 409);
1459
+ await node_fs_promises.default.writeFile(filePath, request.content, "utf8");
1460
+ const body = { hash: hashOf(request.content) };
1461
+ return context.json(body);
1462
+ });
1463
+ app.delete(apiRoutes_default.remove.path, async (context) => {
1464
+ const requested = context.req.query(PATH_QUERY_PARAM);
1465
+ if (await recordedVersions(requested) === void 0 || requested === void 0) return context.json({ error: "This session recorded no changes to that file." }, 404);
1466
+ const filePath = node_path.default.resolve(cwd, requested);
1467
+ if (!await isInsideSession(filePath)) return context.json({ error: "Files outside the session directory are read-only." }, 403);
1468
+ try {
1469
+ await node_fs_promises.default.unlink(filePath);
1470
+ } catch (error) {
1471
+ if (!hasCode(error, "ENOENT")) return context.json({ error: `The file could not be deleted: ${describe(error)}` }, 409);
1472
+ }
1473
+ return context.body(null, 204);
1474
+ });
1475
+ return app;
1476
+ }
1477
+ /** The named export a host imports from this package's built entry. */
1478
+ const api = {
1479
+ basePath: API_BASE_PATH,
1480
+ start(context) {
1481
+ const app = createFileEditsApi({
1482
+ paths: new FileEditPaths(context.environment),
1483
+ ...context.sessionId === void 0 ? {} : { sessionId: context.sessionId },
1484
+ ...context.cwd === void 0 ? {} : { cwd: context.cwd }
1485
+ });
1486
+ return {
1487
+ fetch: (request) => app.fetch(request),
1488
+ close: () => void 0
1489
+ };
1490
+ }
1491
+ };
1492
+ //#endregion
1493
+ //#region src/services/sessionStateSweep/index.ts
1494
+ /**
1495
+ * Remove the state sessions left behind when they never got to clean up.
1496
+ *
1497
+ * A session clears its own timeline and snapshots on shutdown, which covers the
1498
+ * ordinary case and nothing else: a killed process, a crash, or a machine that
1499
+ * lost power leaves both behind, and nothing ever looked at the directory
1500
+ * again. One week of that filled a real checkout with hundreds of megabytes.
1501
+ *
1502
+ * Age is the only signal used, because it is the only one that is true from
1503
+ * another process. A running session appends as it works, so its timeline is
1504
+ * recent; a dead one stops moving at the moment it died.
1505
+ */
1506
+ /**
1507
+ * How long state may sit untouched before a later session removes it.
1508
+ *
1509
+ * Generous on purpose. The cost of waiting is disk, and the cost of being wrong
1510
+ * is deleting a live session's history, so this is set far beyond any plausible
1511
+ * gap between two appends rather than close to it.
1512
+ */
1513
+ const STATE_MAX_AGE_MS = 6048e5;
1514
+ const TIMELINE_SUFFIX = ".jsonl";
1515
+ const SNAPSHOTS_SUFFIX = ".blobs";
1516
+ async function remove(target) {
1517
+ await node_fs_promises.default.rm(target, {
1518
+ recursive: true,
1519
+ force: true
1520
+ });
1521
+ }
1522
+ /** The most recent time anything under this session's state moved. */
1523
+ async function lastTouched(paths) {
1524
+ let newest;
1525
+ for (const target of paths) try {
1526
+ const stat = await node_fs_promises.default.stat(target);
1527
+ if (newest === void 0 || stat.mtimeMs > newest) newest = stat.mtimeMs;
1528
+ } catch {}
1529
+ return newest;
1530
+ }
1531
+ /** How many sessions' worth of state was removed. */
1532
+ async function sweepSessionState(options) {
1533
+ const { directory, keep, removeDirectory = false, now = Date.now(), maxAgeMs = STATE_MAX_AGE_MS } = options;
1534
+ let entries;
1535
+ try {
1536
+ entries = await node_fs_promises.default.readdir(directory);
1537
+ } catch {
1538
+ return 0;
1539
+ }
1540
+ const floor = now - maxAgeMs;
1541
+ const timelines = new Set(entries.filter((entry) => entry.endsWith(TIMELINE_SUFFIX)));
1542
+ let removed = 0;
1543
+ for (const entry of timelines) {
1544
+ const timelinePath = node_path.default.join(directory, entry);
1545
+ if (keep !== void 0 && timelinePath === keep) continue;
1546
+ const snapshotsPath = `${timelinePath.slice(0, -6)}${SNAPSHOTS_SUFFIX}`;
1547
+ const lockPath = `${timelinePath}.lock`;
1548
+ const touched = await lastTouched([
1549
+ timelinePath,
1550
+ snapshotsPath,
1551
+ lockPath
1552
+ ]);
1553
+ if (touched === void 0 || touched >= floor) continue;
1554
+ await remove(snapshotsPath);
1555
+ await remove(timelinePath);
1556
+ await remove(lockPath);
1557
+ removed += 1;
1558
+ }
1559
+ for (const entry of entries) {
1560
+ if (!entry.endsWith(SNAPSHOTS_SUFFIX)) continue;
1561
+ if (timelines.has(`${entry.slice(0, -6)}${TIMELINE_SUFFIX}`)) continue;
1562
+ const orphan = node_path.default.join(directory, entry);
1563
+ const touched = await lastTouched([orphan]);
1564
+ if (touched === void 0 || touched >= floor) continue;
1565
+ await remove(orphan);
1566
+ }
1567
+ if (removeDirectory) try {
1568
+ await node_fs_promises.default.rmdir(directory);
1569
+ } catch {}
1570
+ return removed;
1571
+ }
1572
+ //#endregion
1573
+ //#region src/services/webFilesChannel/index.ts
1574
+ /**
1575
+ * The changed-files channel consumes snapshots emitted by the owning session
1576
+ * facet. Durable timeline reads seed a channel that starts after session startup.
1577
+ */
1578
+ /** Whether the file is still there to be opened. */
1579
+ function stillExists(filePath) {
1580
+ try {
1581
+ return node_fs.default.statSync(filePath).isFile();
1582
+ } catch {
1583
+ return false;
1584
+ }
1585
+ }
1586
+ /**
1587
+ * Reads one session's timeline and presents it as the rows the dock lists.
1588
+ *
1589
+ * A file the session changed and then removed is left out, and so is a path a
1590
+ * bash call only touched: a command that moves a modification time without
1591
+ * changing a byte is not an edit, and listing it buries the ones that are. The
1592
+ * dock is a list of things to open, and a row that can only ever answer "this
1593
+ * is gone" is a dead end; the timeline still holds the change, so a tab already
1594
+ * open on the file keeps working and says so.
1595
+ */
1596
+ function readSessionFiles(timelinePath, cwd) {
1597
+ let content;
1598
+ try {
1599
+ content = node_fs.default.readFileSync(timelinePath, "utf8");
1600
+ } catch {
1601
+ return [];
1602
+ }
1603
+ const events = confirmedChanges(parseTimeline(content));
1604
+ const items = foldEntries(events).filter((entry) => stillExists(entry.path)).map((entry) => ({
1605
+ path: entry.path,
1606
+ relPath: node_path.default.relative(cwd, entry.path) || entry.path,
1607
+ tool: entry.tool,
1608
+ at: entry.at,
1609
+ count: entry.count,
1610
+ diffable: isDiffable(foldVersions(events, entry.path))
1611
+ }));
1612
+ try {
1613
+ return filterDoomIgnoredFiles(items, node_fs.default.readFileSync(node_path.default.join(cwd, ".poignore"), "utf8"));
1614
+ } catch {
1615
+ return items;
1616
+ }
1617
+ }
1618
+ function filesPayload(value) {
1619
+ return typeof value === "object" && value !== null && Array.isArray(value.items);
1620
+ }
1621
+ /**
1622
+ * The files data channel: one direct-event subscription per managed session,
1623
+ * published as { items } payloads under the 'file_edits' frame type.
1624
+ */
1625
+ function createFilesChannel() {
1626
+ return {
1627
+ frameType: filesChannelType,
1628
+ start(host) {
1629
+ const latest = /* @__PURE__ */ new Map();
1630
+ const subscriptions = /* @__PURE__ */ new Map();
1631
+ return {
1632
+ payloadFor(scope) {
1633
+ const items = latest.get(scope.sessionId);
1634
+ return items === void 0 ? void 0 : { items };
1635
+ },
1636
+ sessionAdded(scope) {
1637
+ subscriptions.get(scope.sessionId)?.();
1638
+ const paths = new FileEditPaths(scope.environment);
1639
+ let timelinePath;
1640
+ try {
1641
+ timelinePath = paths.timelinePath(scope.cwd, paths.sessionKey(scope.sessionId));
1642
+ } catch {}
1643
+ const items = timelinePath === void 0 ? [] : readSessionFiles(timelinePath, scope.cwd);
1644
+ latest.set(scope.sessionId, items);
1645
+ host.publish(scope.sessionId, { items });
1646
+ const unsubscribe = host.directEvents.subscribe(filesChannelType, scope.sessionId, (payload) => {
1647
+ if (!filesPayload(payload)) return;
1648
+ latest.set(scope.sessionId, payload.items);
1649
+ host.publish(scope.sessionId, payload);
1650
+ }, { replayLatest: true });
1651
+ subscriptions.set(scope.sessionId, unsubscribe);
1652
+ },
1653
+ sessionRemoved(sessionId) {
1654
+ subscriptions.get(sessionId)?.();
1655
+ subscriptions.delete(sessionId);
1656
+ latest.delete(sessionId);
1657
+ },
1658
+ close() {
1659
+ for (const unsubscribe of subscriptions.values()) unsubscribe();
1660
+ subscriptions.clear();
1661
+ latest.clear();
1662
+ }
1663
+ };
1664
+ }
1665
+ };
1666
+ }
1667
+ //#endregion
1668
+ //#region src/services/fileEditSession/index.ts
1669
+ const DOOM_IGNORE_FILE = ".poignore";
1670
+ function readDoomIgnore(cwd) {
1671
+ try {
1672
+ const matcher = createDoomIgnoreMatcher(node_fs.default.readFileSync(node_path.default.join(cwd, DOOM_IGNORE_FILE), "utf8"));
1673
+ return matcher === void 0 ? void 0 : (filePath) => matcher(node_path.default.relative(cwd, filePath));
1674
+ } catch {
1675
+ return;
1676
+ }
1677
+ }
1678
+ function stringValue(value, key) {
1679
+ const candidate = value[key];
1680
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : void 0;
1681
+ }
1682
+ function createFileEditSession({ agent, host }) {
1683
+ if (!agent) return { api: [api] };
1684
+ const environment = Object.freeze({ ...host.context.environment });
1685
+ const paths = new FileEditPaths(environment);
1686
+ const timeline = new TimelineStore();
1687
+ const snapshots = new NodeSnapshotStoreAdapter();
1688
+ const editTracker = new EditTracker(timeline, snapshots, new NodeTreeManifestAdapter(), { git: new NodeGitStatusAdapter() });
1689
+ if (host.context.directEvents === void 0) throw new Error("File-edit headless facet requires the session direct event bus.");
1690
+ const directEvents = host.context.directEvents;
1691
+ const telemetry = (0, _agimon_ai_po_telemetry.createDoomTelemetry)({
1692
+ serviceName: "doom-file-edit",
1693
+ packageName: "@agimon-ai/po-file-edit",
1694
+ env: environment,
1695
+ enableLogs: true,
1696
+ enableTraces: true
1697
+ });
1698
+ const toolStartedAt = /* @__PURE__ */ new Map();
1699
+ const pending = /* @__PURE__ */ new Set();
1700
+ let closed = false;
1701
+ let disposal;
1702
+ let stopping;
1703
+ let initialized = false;
1704
+ const track = (work) => {
1705
+ const task = work();
1706
+ pending.add(task);
1707
+ task.finally(() => pending.delete(task)).catch(() => {});
1708
+ return task;
1709
+ };
1710
+ let active;
1711
+ let lastRuntime;
1712
+ /**
1713
+ * The channel carries the rows; the status decides whether the group exists.
1714
+ *
1715
+ * A cockpit drives this facet and never the Pi runtime, so without the status
1716
+ * the dock had the file list on the wire and no frame to draw it in. The empty
1717
+ * string is deliberate: the group declares `hideWhenEmpty`, so a session that
1718
+ * has edited nothing publishes a key with no content and stays hidden.
1719
+ */
1720
+ const publish = (runtime) => {
1721
+ const items = readSessionFiles(runtime.timelinePath, runtime.context.cwd);
1722
+ directEvents.publish(filesChannelType, runtime.context.sessionId, { items });
1723
+ runtime.context.client.setStatus(filesStatusKey, items.length === 0 ? "" : `${items.length} ${items.length === 1 ? "file" : "files"}`);
1724
+ };
1725
+ const stopActivity = () => {
1726
+ if (stopping) return stopping;
1727
+ const runtime = active;
1728
+ active = void 0;
1729
+ stopping = (async () => {
1730
+ await Promise.allSettled(pending);
1731
+ await editTracker.flush();
1732
+ editTracker.reset();
1733
+ toolStartedAt.clear();
1734
+ runtime?.context.client.setStatus(filesStatusKey, void 0);
1735
+ })();
1736
+ return stopping;
1737
+ };
1738
+ const dispose = () => {
1739
+ if (disposal) return disposal;
1740
+ closed = true;
1741
+ disposal = (async () => {
1742
+ try {
1743
+ await stopActivity();
1744
+ if (lastRuntime) {
1745
+ if (!environment[_agimon_ai_po_core_childProcess.SUBAGENT_CHILD_ENV]) {
1746
+ await timeline.clear();
1747
+ await snapshots.clear();
1748
+ }
1749
+ await telemetry.recordEvent("doom_file_edit.timeline_finished", { outcome: "closed" });
1750
+ }
1751
+ } finally {
1752
+ await telemetry.shutdown();
1753
+ }
1754
+ })();
1755
+ return disposal;
1756
+ };
1757
+ return {
1758
+ api: [api],
1759
+ activities: [{
1760
+ name: "file-edits",
1761
+ start: async (executionContext) => {
1762
+ if (closed) throw new Error("File-edit session is closed.");
1763
+ if (active) return stopActivity;
1764
+ if (stopping) await stopping;
1765
+ if (closed) throw new Error("File-edit session is closed.");
1766
+ stopping = void 0;
1767
+ const sessionKey = paths.sessionKey(executionContext.sessionId);
1768
+ const timelinePath = paths.timelinePath(executionContext.cwd, sessionKey);
1769
+ const snapshotsPath = paths.snapshotsPath(executionContext.cwd, sessionKey);
1770
+ timeline.initialize(timelinePath);
1771
+ snapshots.initialize(snapshotsPath);
1772
+ const isIgnored = readDoomIgnore(executionContext.cwd);
1773
+ editTracker.reset({
1774
+ exclude: [
1775
+ timelinePath,
1776
+ `${timelinePath}.lock`,
1777
+ snapshotsPath
1778
+ ],
1779
+ ...isIgnored === void 0 ? {} : { isIgnored }
1780
+ });
1781
+ const runtime = {
1782
+ context: executionContext,
1783
+ timelinePath,
1784
+ snapshotsPath
1785
+ };
1786
+ active = runtime;
1787
+ lastRuntime = runtime;
1788
+ publish(runtime);
1789
+ track(async () => {
1790
+ try {
1791
+ await sweepSessionState({
1792
+ directory: paths.stateDirectory(),
1793
+ keep: timelinePath
1794
+ });
1795
+ const legacy = paths.legacyStateDirectory(executionContext.cwd);
1796
+ if (legacy !== void 0 && legacy !== paths.stateDirectory()) await sweepSessionState({
1797
+ directory: legacy,
1798
+ removeDirectory: true
1799
+ });
1800
+ } catch {}
1801
+ });
1802
+ if (!initialized) {
1803
+ initialized = true;
1804
+ await track(() => telemetry.recordEvent("doom_file_edit.timeline_started", { outcome: "initialized" }));
1805
+ }
1806
+ return () => active === runtime ? stopActivity() : Promise.resolve();
1807
+ }
1808
+ }],
1809
+ hooks: [{
1810
+ event: "tool_execution_start",
1811
+ handle: (event, executionContext) => {
1812
+ const runtime = active;
1813
+ const toolCallId = stringValue(event, "toolCallId");
1814
+ const toolName = stringValue(event, "toolName");
1815
+ if (runtime === void 0 || runtime.context.sessionId !== executionContext.sessionId || !toolCallId || !toolName) return;
1816
+ return track(async () => {
1817
+ toolStartedAt.set(toolCallId, Date.now());
1818
+ const hookStartedAt = Date.now();
1819
+ await editTracker.start(toolCallId, toolName, event.args, executionContext.cwd);
1820
+ if (active !== runtime) return;
1821
+ await telemetry.recordEvent("doom_file_edit.edit_started", {
1822
+ "tool.name": toolName,
1823
+ hook_ms: Date.now() - hookStartedAt
1824
+ });
1825
+ });
1826
+ }
1827
+ }, {
1828
+ event: "tool_execution_end",
1829
+ handle: (event, executionContext) => {
1830
+ const runtime = active;
1831
+ const toolCallId = stringValue(event, "toolCallId");
1832
+ const toolName = stringValue(event, "toolName");
1833
+ if (runtime === void 0 || runtime.context.sessionId !== executionContext.sessionId || !toolCallId || !toolName) return;
1834
+ return track(async () => {
1835
+ const startedAt = toolStartedAt.get(toolCallId);
1836
+ toolStartedAt.delete(toolCallId);
1837
+ const hookStartedAt = Date.now();
1838
+ const isError = event.isError === true;
1839
+ await editTracker.end(toolCallId, isError, executionContext.cwd);
1840
+ if (active !== runtime) return;
1841
+ publish(runtime);
1842
+ await telemetry.recordEvent("doom_file_edit.edit_finished", {
1843
+ "tool.name": toolName,
1844
+ "tool.result.error": isError,
1845
+ ...startedAt === void 0 ? {} : { duration_ms: Date.now() - startedAt },
1846
+ hook_ms: Date.now() - hookStartedAt,
1847
+ outcome: isError ? "failed" : "completed"
1848
+ });
1849
+ });
1850
+ }
1851
+ }],
1852
+ onDispose: dispose
1853
+ };
1854
+ }
1855
+ //#endregion
1856
+ //#region src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts
1857
+ const createFileEditServerRoot = (context) => {
1858
+ const runtime = createFileEditSession(context);
1859
+ return {
1860
+ value: runtime,
1861
+ activities: runtime.activities,
1862
+ onDispose: runtime.onDispose
1863
+ };
1864
+ };
1865
+ //#endregion
1866
+ //#region src/extensions/workspaces/sessions/(backend)/root.server.ts
1867
+ var root_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoot)(createFileEditServerRoot);
1868
+ //#endregion
1869
+ //#region src/extensions/(backend)/channel/fileEdits.server.ts
1870
+ var fileEdits_server_default = (0, _agimon_ai_po_core_extensionFile.defineChannel)(function fileEditsChannel() {
1871
+ return createFilesChannel();
1872
+ });
1873
+ //#endregion
1874
+ //#region src/extensions/workspaces/sessions/(backend)/api/file-edits/route.server.ts
1875
+ /**
1876
+ * Mounts this package's HTTP API at `file-edits`, which the folder names.
1877
+ *
1878
+ * Until this file existed the API was never mounted at all. The session root
1879
+ * returns a runtime carrying `api`, but a root's `api` is not folded into the
1880
+ * generated entry the way its services and activities are, so the registration
1881
+ * was dropped in silence and the cockpit called routes that answered 404.
1882
+ */
1883
+ var route_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoute)(api);
1884
+ //#endregion
1885
+ //#region src/extensions/workspaces/sessions/(backend)/hook/tool-execution-end.server.ts
1886
+ var tool_execution_end_server_default = (0, _agimon_ai_po_core_extensionFile.defineHook)((context) => context.root.hooks[1]);
1887
+ //#endregion
1888
+ //#region src/extensions/workspaces/sessions/(backend)/hook/tool-execution-start.server.ts
1889
+ var tool_execution_start_server_default = (0, _agimon_ai_po_core_extensionFile.defineHook)((context) => context.root.hooks[0]);
1890
+ //#endregion
1891
+ //#region generated/server.ts
1892
+ const at = (value, context) => typeof value === "function" ? value(context) : value;
1893
+ const via = (identity, value) => value === void 0 ? void 0 : {
1894
+ ...identity,
1895
+ ...value
1896
+ };
1897
+ const facet = (0, _agimon_ai_po_core_serverFacet.defineServerPlugin)({
1898
+ name: "@agimon-ai/po-file-edit",
1899
+ providerOverrides: (0, _agimon_ai_po_core_serverFacet.composeServerProviderOverrides)(root_server_default),
1900
+ global: () => ({ get channels() {
1901
+ return [() => via({ frameType: "file_edits" }, fileEdits_server_default())];
1902
+ } }),
1903
+ workspace: () => ({ get channels() {
1904
+ return [() => via({ frameType: "file_edits" }, fileEdits_server_default())];
1905
+ } }),
1906
+ session: async (context) => {
1907
+ const scopeSession = await root_server_default(context);
1908
+ const contextSession = {
1909
+ ...context,
1910
+ root: scopeSession.value
1911
+ };
1912
+ return {
1913
+ ...(0, _agimon_ai_po_core_extensionFile.composeRootHooks)(scopeSession),
1914
+ get api() {
1915
+ return [at(route_server_default, contextSession)];
1916
+ },
1917
+ get channels() {
1918
+ return [() => via({ frameType: "file_edits" }, fileEdits_server_default())];
1919
+ },
1920
+ get hooks() {
1921
+ return [via({ event: "tool_execution_end" }, at(tool_execution_end_server_default, contextSession)), via({ event: "tool_execution_start" }, at(tool_execution_start_server_default, contextSession))];
1922
+ },
1923
+ services: [...scopeSession.services ?? []],
1924
+ get activities() {
1925
+ return [...scopeSession.activities ?? []];
1926
+ }
1927
+ };
1928
+ }
1929
+ });
1930
+ //#endregion
1931
+ exports.default = facet;
1932
+ exports.facet = facet;
1933
+
1934
+ //# sourceMappingURL=server.cjs.map