@agimon-ai/po-file-edit 0.0.0-alpha.2 → 0.0.0-alpha.21

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.
@@ -1,49 +1,203 @@
1
- import { composeServerProviderOverrides, defineServerPlugin } from "@agimon-ai/po-core/serverFacet";
2
- import { composeRootHooks, defineChannel, defineHook, defineRoot, defineRoute } from "@agimon-ai/po-core/extensionFile";
3
- import fs from "node:fs";
1
+ import { composeServerProviderOverrides, defineServerPlugin } from "@agimon-ai/po-core/serverPlugin";
2
+ import { composeRootHooks, composeRootInjects, defineChannel, defineHook, defineRoot, defineRoute, runCleanups } from "@agimon-ai/po-core/extensionFile";
3
+ import { PO_CHILD_SESSION_HOOK_REGISTRY_SERVICE, readPoChildSessionLabels } from "@agimon-ai/po-core/childSession";
4
+ import { WORKFLOW_STEP_SESSION_PROVENANCE } from "@agimon-ai/po-core/hubChannel";
5
+ import fs from "node:fs/promises";
4
6
  import path from "node:path";
7
+ import { parseFileHeader } from "@agimon-ai/po-hashline";
8
+ import { computeFileTag, displayPath, resolveInputPath, resolveReadInputPath } from "@agimon-ai/po-hashline/files";
9
+ import fs$1 from "node:fs";
5
10
  import { SUBAGENT_CHILD_ENV, SUBAGENT_PARENT_SESSION_ENV } from "@agimon-ai/po-core/childProcess";
6
- import { createDoomTelemetry } from "@agimon-ai/po-telemetry";
7
- import ignore from "ignore";
8
- import { execFile, execFileSync } from "node:child_process";
11
+ import { createPoTelemetry } from "@agimon-ai/po-telemetry";
9
12
  import { createHash } from "node:crypto";
10
13
  import { piAgentDirectory } from "@agimon-ai/po-core/piSettings";
14
+ import { execFile } from "node:child_process";
11
15
  import { promisify } from "node:util";
12
- import fs$1 from "node:fs/promises";
16
+ import ignore from "ignore";
17
+ import os from "node:os";
18
+ import { loadPoConfig, resolvePlanningPlansDirectory } from "@agimon-ai/po-config";
13
19
  import { Hono } from "hono";
14
- //#region src/services/doomIgnore/index.ts
15
- function matchablePath(relPath) {
16
- const normalized = relPath.replace(/\\/gu, "/");
17
- if (normalized.length === 0 || normalized.includes("\0") || normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) return;
18
- return normalized;
20
+ //#region src/constants/editPresence.ts
21
+ const PRESENCE_NOTE_TITLE = "[Po: other active agents edited files in this result]";
22
+ const PRESENCE_NOTE_GUIDANCE = "Preserve unrelated edits. If coordination would help, you can use intercom to send to the session id, or ask the subagent by name if it is on your team. Contact is optional; this notice does not block editing.";
23
+ //#endregion
24
+ //#region src/services/editPresence/index.ts
25
+ const PATH_TOOLS = /* @__PURE__ */ new Set([
26
+ "read",
27
+ "edit",
28
+ "write"
29
+ ]);
30
+ const EDIT_TOOLS = /* @__PURE__ */ new Set(["edit", "write"]);
31
+ const MAX_GREP_FILES = 50;
32
+ const MAX_NOTE_LINES = 10;
33
+ const MINUTE_MS = 6e4;
34
+ const SHORT_ID_LENGTH = 8;
35
+ /** Pi's built-in grep prints match rows as `relative/path:line: text`. */
36
+ const PI_GREP_ROW = /^(.+?):(\d+): /u;
37
+ function forgetPresence(ledger, agentKey) {
38
+ for (const [file, entries] of ledger) {
39
+ entries.delete(agentKey);
40
+ if (entries.size === 0) ledger.delete(file);
41
+ }
42
+ }
43
+ function stringArg(args, key) {
44
+ if (typeof args !== "object" || args === null) return void 0;
45
+ const value = args[key];
46
+ return typeof value === "string" && value.length > 0 ? value : void 0;
47
+ }
48
+ function textOf(content) {
49
+ return content.map((part) => part.type === "text" && "text" in part && typeof part.text === "string" ? part.text : "").join("\n");
50
+ }
51
+ async function grepFiles(call) {
52
+ const lines = textOf(call.content).split("\n");
53
+ const headers = lines.flatMap((line) => {
54
+ const header = parseFileHeader(line);
55
+ return header === void 0 ? [] : [resolveInputPath(header.path, call.cwd)];
56
+ });
57
+ if (headers.length > 0) return [...new Set(headers)].slice(0, MAX_GREP_FILES);
58
+ const rows = lines.flatMap((line) => {
59
+ const match = PI_GREP_ROW.exec(line)?.[1];
60
+ return match === void 0 ? [] : [match];
61
+ });
62
+ if (rows.length === 0) return [];
63
+ const searchPath = resolveInputPath(stringArg(call.args, "path") ?? ".", call.cwd);
64
+ if ((await fs.stat(searchPath).catch(() => void 0))?.isFile()) return [searchPath];
65
+ return [...new Set(rows.map((row) => path.resolve(searchPath, row)))].slice(0, MAX_GREP_FILES);
66
+ }
67
+ async function touchedFiles(call) {
68
+ if (PATH_TOOLS.has(call.toolName)) {
69
+ const input = stringArg(call.args, "path");
70
+ return input === void 0 ? [] : [await resolveReadInputPath(input, call.cwd)];
71
+ }
72
+ return call.toolName === "grep" ? grepFiles(call) : [];
73
+ }
74
+ function ago(elapsed) {
75
+ const minutes = Math.floor(elapsed / MINUTE_MS);
76
+ return minutes < 1 ? "under 1m ago" : `${minutes}m ago`;
77
+ }
78
+ function liveOthers(ledger, file, self) {
79
+ const entries = ledger.get(file);
80
+ if (entries === void 0) return [];
81
+ const live = [];
82
+ for (const [key, entry] of entries) {
83
+ if (key === self) continue;
84
+ if (entry.agent.isLive()) live.push(entry);
85
+ else entries.delete(key);
86
+ }
87
+ return live.sort((left, right) => right.at - left.at);
88
+ }
89
+ function record(ledger, file, agent, tag, at) {
90
+ const entries = ledger.get(file) ?? /* @__PURE__ */ new Map();
91
+ entries.set(agent.key, {
92
+ agent,
93
+ tag,
94
+ at
95
+ });
96
+ ledger.set(file, entries);
97
+ }
98
+ function renderNote(lines) {
99
+ const shown = lines.slice(0, MAX_NOTE_LINES);
100
+ const more = lines.length - shown.length;
101
+ return [
102
+ PRESENCE_NOTE_TITLE,
103
+ ...shown,
104
+ ...more > 0 ? [`- and ${more} more`] : [],
105
+ PRESENCE_NOTE_GUIDANCE
106
+ ].join("\n");
19
107
  }
20
108
  /**
21
- * Builds the project's ignore test, or nothing when there are no rules.
22
- *
23
- * Shared because the same rules are needed twice: once when deciding what to
24
- * record, and once when deciding what to list. A caller that built its own
25
- * matcher would let the two drift apart.
26
- *
27
- * The test answers false for anything it cannot safely match, so a path that
28
- * escapes the project stays visible rather than being hidden by accident.
109
+ * Notes live editors of the files a tool call touched, then records the caller's own successful edit.
110
+ * Returns the content with one note appended, or undefined when there is nothing to add, so callers
111
+ * never rewrite a result needlessly.
29
112
  */
30
- function createDoomIgnoreMatcher(content) {
31
- try {
32
- if (content.trim().length === 0) return void 0;
33
- const matcher = ignore().add(content);
34
- return (relPath) => {
35
- const candidate = matchablePath(relPath);
36
- return candidate !== void 0 && matcher.ignores(candidate);
37
- };
38
- } catch {
39
- return;
113
+ async function observePresence(ledger, agent, call, now = Date.now()) {
114
+ const recording = EDIT_TOOLS.has(call.toolName) && !call.isError;
115
+ if (!recording && ledger.size === 0) return void 0;
116
+ const lines = [];
117
+ for (const file of await touchedFiles(call)) {
118
+ const real = await fs.realpath(file).catch(() => void 0);
119
+ if (real === void 0) continue;
120
+ const others = liveOthers(ledger, real, agent.key);
121
+ let bytes;
122
+ if (others.length > 0) {
123
+ bytes = await fs.readFile(real).catch(() => void 0);
124
+ const current = bytes === void 0 ? void 0 : computeFileTag(bytes);
125
+ const shown = displayPath(file, call.cwd);
126
+ for (const entry of others) {
127
+ const state = entry.tag === current ? "unchanged" : "changed";
128
+ lines.push(`- ${shown}: ${entry.agent.label()}, ${ago(now - entry.at)}, file ${state} since their edit.`);
129
+ }
130
+ }
131
+ if (recording) {
132
+ bytes ??= await fs.readFile(real).catch(() => void 0);
133
+ if (bytes !== void 0) record(ledger, real, agent, computeFileTag(bytes), now);
134
+ }
40
135
  }
136
+ if (lines.length === 0) return void 0;
137
+ return [...call.content, {
138
+ type: "text",
139
+ text: renderNote(lines)
140
+ }];
41
141
  }
42
- /** Applies project-local gitignore rules while keeping unsafe candidates visible. */
43
- function filterDoomIgnoredFiles(items, content) {
44
- const ignored = createDoomIgnoreMatcher(content);
45
- if (ignored === void 0) return items;
46
- return items.filter((item) => !ignored(item.relPath));
142
+ /** Edit presence for one hub session and the in-process children it spawns. */
143
+ function createSessionPresence(options) {
144
+ const { sessionId, ledger } = options;
145
+ const sessionAgent = {
146
+ key: `session:${sessionId}`,
147
+ label: () => `session ${sessionId}${options.workflowStep ? " (workflow step)" : ""}`,
148
+ contact: sessionId,
149
+ isLive: options.isLive
150
+ };
151
+ const safely = async (work) => {
152
+ try {
153
+ return await work();
154
+ } catch (error) {
155
+ options.onError?.(error);
156
+ return;
157
+ }
158
+ };
159
+ return {
160
+ toolResult: {
161
+ event: "tool_result",
162
+ async handle(event, execution) {
163
+ const toolName = event.toolName;
164
+ if (execution.sessionId !== sessionId || typeof toolName !== "string" || !Array.isArray(event.content)) return;
165
+ const content = event.content;
166
+ const next = await safely(() => observePresence(ledger, sessionAgent, {
167
+ toolName,
168
+ args: event.args,
169
+ content,
170
+ isError: event.isError === true,
171
+ cwd: execution.cwd
172
+ }));
173
+ return next === void 0 ? void 0 : { content: next };
174
+ }
175
+ },
176
+ childHooks: { bind(scope) {
177
+ const { runId, parentSessionId, agent } = scope.request;
178
+ const child = {
179
+ key: `run:${runId}`,
180
+ label: () => `${options.labelFor(runId) ?? `${agent} (run ${runId.slice(0, SHORT_ID_LENGTH)})`} (subagent of session ${parentSessionId})`,
181
+ contact: parentSessionId,
182
+ isLive: () => !scope.signal.aborted
183
+ };
184
+ return {
185
+ beforeTool: () => void 0,
186
+ async afterTool(event) {
187
+ const next = await safely(() => observePresence(ledger, child, {
188
+ toolName: event.toolName,
189
+ args: event.args,
190
+ content: event.content,
191
+ isError: event.isError,
192
+ cwd: scope.request.cwd || options.cwd
193
+ }));
194
+ return next === void 0 ? void 0 : { content: next };
195
+ },
196
+ dispose: async () => forgetPresence(ledger, child.key)
197
+ };
198
+ } },
199
+ dispose: () => forgetPresence(ledger, sessionAgent.key)
200
+ };
47
201
  }
48
202
  //#endregion
49
203
  //#region src/services/lineDiff/index.ts
@@ -493,7 +647,7 @@ var EditTracker = class {
493
647
  //#endregion
494
648
  //#region src/services/fileEditPaths/index.ts
495
649
  /** The directory this package owns, under whichever root holds agent state. */
496
- const STATE_DIRECTORY = "doom-file-edit";
650
+ const STATE_DIRECTORY = "po-file-edit";
497
651
  function hash(value) {
498
652
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
499
653
  }
@@ -511,7 +665,7 @@ var FileEditPaths = class {
511
665
  }
512
666
  /**
513
667
  * Where every session's state lives: the Pi agent directory, the same root
514
- * every other Doom package writes to, and never inside the working tree.
668
+ * every other Po package writes to, and never inside the working tree.
515
669
  *
516
670
  * An earlier layout put this in the repository's git common directory. That
517
671
  * is git's own storage: nothing in git's lifecycle prunes a directory we add
@@ -531,8 +685,8 @@ var FileEditPaths = class {
531
685
  */
532
686
  timelinePath(cwd, sessionKey) {
533
687
  const directory = this.stateDirectory();
534
- fs.mkdirSync(directory, { recursive: true });
535
- return path.join(directory, `${hash(fs.realpathSync(cwd))}-${hash(sessionKey)}.jsonl`);
688
+ fs$1.mkdirSync(directory, { recursive: true });
689
+ return path.join(directory, `${hash(fs$1.realpathSync(cwd))}-${hash(sessionKey)}.jsonl`);
536
690
  }
537
691
  /**
538
692
  * Where this session's content snapshots live: beside its timeline and named
@@ -541,31 +695,6 @@ var FileEditPaths = class {
541
695
  snapshotsPath(cwd, sessionKey) {
542
696
  return `${this.timelinePath(cwd, sessionKey).replace(/\.jsonl$/u, "")}.blobs`;
543
697
  }
544
- legacyStateDirectory(cwd) {
545
- const common = this.gitCommonDirectory(cwd);
546
- return common === void 0 ? void 0 : path.join(common, STATE_DIRECTORY);
547
- }
548
- /** Only still read so the state an older build left behind can be cleared away. */
549
- gitCommonDirectory(cwd) {
550
- try {
551
- return execFileSync("git", [
552
- "rev-parse",
553
- "--path-format=absolute",
554
- "--git-common-dir"
555
- ], {
556
- cwd,
557
- encoding: "utf8",
558
- stdio: [
559
- "ignore",
560
- "pipe",
561
- "ignore"
562
- ]
563
- }).trim() || void 0;
564
- } catch (error) {
565
- if (error instanceof Error) return void 0;
566
- throw error;
567
- }
568
- }
569
698
  };
570
699
  //#endregion
571
700
  //#region src/services/gitStatus/index.ts
@@ -653,6 +782,41 @@ var NodeGitStatusAdapter = class {
653
782
  }
654
783
  }
655
784
  };
785
+ //#endregion
786
+ //#region src/services/poIgnore/index.ts
787
+ function matchablePath(relPath) {
788
+ const normalized = relPath.replace(/\\/gu, "/");
789
+ if (normalized.length === 0 || normalized.includes("\0") || normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) return;
790
+ return normalized;
791
+ }
792
+ /**
793
+ * Builds the project's ignore test, or nothing when there are no rules.
794
+ *
795
+ * Shared because the same rules are needed twice: once when deciding what to
796
+ * record, and once when deciding what to list. A caller that built its own
797
+ * matcher would let the two drift apart.
798
+ *
799
+ * The test answers false for anything it cannot safely match, so a path that
800
+ * escapes the project stays visible rather than being hidden by accident.
801
+ */
802
+ function createPoIgnoreMatcher(content) {
803
+ try {
804
+ if (content.trim().length === 0) return void 0;
805
+ const matcher = ignore().add(content);
806
+ return (relPath) => {
807
+ const candidate = matchablePath(relPath);
808
+ return candidate !== void 0 && matcher.ignores(candidate);
809
+ };
810
+ } catch {
811
+ return;
812
+ }
813
+ }
814
+ /** Applies project-local gitignore rules while keeping unsafe candidates visible. */
815
+ function filterPoIgnoredFiles(items, content) {
816
+ const ignored = createPoIgnoreMatcher(content);
817
+ if (ignored === void 0) return items;
818
+ return items.filter((item) => !ignored(item.relPath));
819
+ }
656
820
  /** How much of a file is read before deciding it is binary. */
657
821
  const BINARY_SAMPLE_BYTES$1 = 8192;
658
822
  const MISSING_CODES = /* @__PURE__ */ new Set([
@@ -672,7 +836,7 @@ var NodeSnapshotStoreAdapter = class {
672
836
  async capture(filePath) {
673
837
  let raw;
674
838
  try {
675
- raw = await fs$1.readFile(filePath);
839
+ raw = await fs.readFile(filePath);
676
840
  } catch (error) {
677
841
  if (hasCode$2(error, MISSING_CODES)) return void 0;
678
842
  throw error;
@@ -685,19 +849,19 @@ var NodeSnapshotStoreAdapter = class {
685
849
  const hash = createHash("sha256").update(content, "utf8").digest("hex");
686
850
  const blobPath = path.join(this.requireDirectory(), hash);
687
851
  try {
688
- await fs$1.access(blobPath);
852
+ await fs.access(blobPath);
689
853
  return hash;
690
854
  } catch (error) {
691
855
  if (!hasCode$2(error, MISSING_CODES)) throw error;
692
856
  }
693
- await fs$1.mkdir(this.requireDirectory(), { recursive: true });
694
- await fs$1.writeFile(blobPath, content, "utf8");
857
+ await fs.mkdir(this.requireDirectory(), { recursive: true });
858
+ await fs.writeFile(blobPath, content, "utf8");
695
859
  return hash;
696
860
  }
697
861
  async read(hash) {
698
862
  if (!/^[0-9a-f]{64}$/u.test(hash)) return void 0;
699
863
  try {
700
- return await fs$1.readFile(path.join(this.requireDirectory(), hash), "utf8");
864
+ return await fs.readFile(path.join(this.requireDirectory(), hash), "utf8");
701
865
  } catch (error) {
702
866
  if (hasCode$2(error, MISSING_CODES)) return void 0;
703
867
  throw error;
@@ -705,7 +869,7 @@ var NodeSnapshotStoreAdapter = class {
705
869
  }
706
870
  async clear() {
707
871
  if (this.directory === void 0) return;
708
- await fs$1.rm(this.directory, {
872
+ await fs.rm(this.directory, {
709
873
  recursive: true,
710
874
  force: true
711
875
  });
@@ -869,7 +1033,7 @@ var TimelineStore = class {
869
1033
  this.filePath = filePath;
870
1034
  }
871
1035
  async append(event) {
872
- await this.withLock(async () => fs$1.appendFile(this.requirePath(), `${JSON.stringify(event)}\n`, "utf8"));
1036
+ await this.withLock(async () => fs.appendFile(this.requirePath(), `${JSON.stringify(event)}\n`, "utf8"));
873
1037
  }
874
1038
  async list() {
875
1039
  return foldEntries(confirmedChanges(await this.events()));
@@ -879,7 +1043,7 @@ var TimelineStore = class {
879
1043
  }
880
1044
  async clear() {
881
1045
  try {
882
- await fs$1.unlink(this.requirePath());
1046
+ await fs.unlink(this.requirePath());
883
1047
  } catch (error) {
884
1048
  if (!hasCode$1(error, "ENOENT")) throw error;
885
1049
  }
@@ -888,7 +1052,7 @@ var TimelineStore = class {
888
1052
  async events() {
889
1053
  let content;
890
1054
  try {
891
- content = await fs$1.readFile(this.requirePath(), "utf8");
1055
+ content = await fs.readFile(this.requirePath(), "utf8");
892
1056
  } catch (error) {
893
1057
  if (hasCode$1(error, "ENOENT")) return [];
894
1058
  throw error;
@@ -912,7 +1076,7 @@ var TimelineStore = class {
912
1076
  /** Waits for the lock file, breaking one an earlier process left behind. */
913
1077
  async acquire(lockPath) {
914
1078
  for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) try {
915
- return await fs$1.open(lockPath, "wx");
1079
+ return await fs.open(lockPath, "wx");
916
1080
  } catch (error) {
917
1081
  if (!hasCode$1(error, "EEXIST")) throw error;
918
1082
  if (await this.breakStale(lockPath)) continue;
@@ -923,9 +1087,9 @@ var TimelineStore = class {
923
1087
  /** Whether the lock is now free to take, having removed it when its holder is gone. */
924
1088
  async breakStale(lockPath) {
925
1089
  try {
926
- const stats = await fs$1.stat(lockPath);
1090
+ const stats = await fs.stat(lockPath);
927
1091
  if (Date.now() - stats.mtimeMs < LOCK_STALE_MS) return false;
928
- await fs$1.unlink(lockPath);
1092
+ await fs.unlink(lockPath);
929
1093
  return true;
930
1094
  } catch (error) {
931
1095
  if (hasCode$1(error, "ENOENT")) return true;
@@ -934,7 +1098,7 @@ var TimelineStore = class {
934
1098
  }
935
1099
  async release(lockPath) {
936
1100
  try {
937
- await fs$1.unlink(lockPath);
1101
+ await fs.unlink(lockPath);
938
1102
  } catch (error) {
939
1103
  if (!hasCode$1(error, "ENOENT")) console.warn(`Could not remove timeline lock: ${String(error)}`);
940
1104
  }
@@ -1024,7 +1188,7 @@ var NodeTreeManifestAdapter = class {
1024
1188
  }
1025
1189
  async fingerprint(filePath) {
1026
1190
  try {
1027
- const stat = await fs$1.stat(filePath);
1191
+ const stat = await fs.stat(filePath);
1028
1192
  return stat.isFile() ? fingerprintOf(stat.size, stat.mtimeMs) : void 0;
1029
1193
  } catch {
1030
1194
  return;
@@ -1032,7 +1196,7 @@ var NodeTreeManifestAdapter = class {
1032
1196
  }
1033
1197
  async modifiedAt(filePath) {
1034
1198
  try {
1035
- const stat = await fs$1.stat(filePath);
1199
+ const stat = await fs.stat(filePath);
1036
1200
  return stat.isFile() ? stat.mtimeMs : void 0;
1037
1201
  } catch {
1038
1202
  return;
@@ -1053,7 +1217,7 @@ var NodeTreeManifestAdapter = class {
1053
1217
  if (depth > this.maxDepth) return true;
1054
1218
  let listing;
1055
1219
  try {
1056
- listing = await fs$1.readdir(directory, { withFileTypes: true });
1220
+ listing = await fs.readdir(directory, { withFileTypes: true });
1057
1221
  } catch {
1058
1222
  return false;
1059
1223
  }
@@ -1070,7 +1234,7 @@ var NodeTreeManifestAdapter = class {
1070
1234
  }
1071
1235
  if (!entry.isFile()) continue;
1072
1236
  try {
1073
- const stat = await fs$1.stat(entryPath);
1237
+ const stat = await fs.stat(entryPath);
1074
1238
  entries.set(entryPath, fingerprintOf(stat.size, stat.mtimeMs));
1075
1239
  } catch {}
1076
1240
  }
@@ -1082,7 +1246,7 @@ var NodeTreeManifestAdapter = class {
1082
1246
  /** The frame type the hub publishes and the web plugin claims; declared in the poWeb block too. */
1083
1247
  const filesChannelType = "file_edits";
1084
1248
  /** Footer status key whose presence shows the activity group; the extension publishes it. */
1085
- const filesStatusKey = "doom-file-edit-files";
1249
+ const filesStatusKey = "po-file-edit-files";
1086
1250
  //#endregion
1087
1251
  //#region src/types/apiRoutes.ts
1088
1252
  /**
@@ -1201,7 +1365,7 @@ function describe(error) {
1201
1365
  async function readWorking(filePath) {
1202
1366
  let raw;
1203
1367
  try {
1204
- raw = await fs$1.readFile(filePath);
1368
+ raw = await fs.readFile(filePath);
1205
1369
  } catch {
1206
1370
  return {
1207
1371
  content: "",
@@ -1241,8 +1405,8 @@ async function isRealPathInside(root, candidate) {
1241
1405
  let realRoot;
1242
1406
  let real;
1243
1407
  try {
1244
- realRoot = await fs$1.realpath(root);
1245
- real = await fs$1.realpath(candidate);
1408
+ realRoot = await fs.realpath(root);
1409
+ real = await fs.realpath(candidate);
1246
1410
  } catch (error) {
1247
1411
  return hasCode(error, "ENOENT");
1248
1412
  }
@@ -1369,6 +1533,13 @@ function createFileEditsApi(options = {}) {
1369
1533
  const root = path.resolve(cwd);
1370
1534
  return isInside(root, filePath) && await isRealPathInside(root, filePath);
1371
1535
  };
1536
+ /** The Po-owned folder holding the file, checked the same two ways as the working directory. */
1537
+ const readableRootOf = async (filePath) => {
1538
+ for (const root of options.readableRoots?.() ?? []) {
1539
+ const resolved = path.resolve(root);
1540
+ if (isInside(resolved, filePath) && await isRealPathInside(resolved, filePath)) return resolved;
1541
+ }
1542
+ };
1372
1543
  app.get(routes.detail.path, async (context) => {
1373
1544
  const requested = context.req.query(PATH_QUERY_PARAM);
1374
1545
  const versions = await recordedVersions(requested);
@@ -1401,10 +1572,11 @@ function createFileEditsApi(options = {}) {
1401
1572
  if (cwd === "") return context.json({ error: "This session has no working directory to read from." }, 403);
1402
1573
  const root = path.resolve(cwd);
1403
1574
  const filePath = path.resolve(root, requested);
1404
- if (!await isInsideSession(filePath)) return context.json({ error: "The path leaves the session directory." }, 403);
1575
+ const inSession = await isInsideSession(filePath);
1576
+ if (!inSession && await readableRootOf(filePath) === void 0) return context.json({ error: "The path leaves the session directory." }, 403);
1405
1577
  const body = {
1406
1578
  path: filePath,
1407
- relPath: path.relative(root, filePath),
1579
+ relPath: inSession ? path.relative(root, filePath) : filePath,
1408
1580
  working: await readWorking(filePath)
1409
1581
  };
1410
1582
  return context.json(body);
@@ -1426,7 +1598,7 @@ function createFileEditsApi(options = {}) {
1426
1598
  error: "The file changed since it was opened.",
1427
1599
  hash: current.hash
1428
1600
  }, 409);
1429
- await fs$1.writeFile(filePath, request.content, "utf8");
1601
+ await fs.writeFile(filePath, request.content, "utf8");
1430
1602
  const body = { hash: hashOf(request.content) };
1431
1603
  return context.json(body);
1432
1604
  });
@@ -1436,7 +1608,7 @@ function createFileEditsApi(options = {}) {
1436
1608
  const filePath = path.resolve(cwd, requested);
1437
1609
  if (!await isInsideSession(filePath)) return context.json({ error: "Files outside the session directory are read-only." }, 403);
1438
1610
  try {
1439
- await fs$1.unlink(filePath);
1611
+ await fs.unlink(filePath);
1440
1612
  } catch (error) {
1441
1613
  if (!hasCode(error, "ENOENT")) return context.json({ error: `The file could not be deleted: ${describe(error)}` }, 409);
1442
1614
  }
@@ -1448,10 +1620,15 @@ function createFileEditsApi(options = {}) {
1448
1620
  const api = {
1449
1621
  basePath: API_BASE_PATH,
1450
1622
  start(context) {
1623
+ const environment = context.environment ?? process.env;
1624
+ const cwd = context.cwd;
1451
1625
  const app = createFileEditsApi({
1452
1626
  paths: new FileEditPaths(context.environment),
1453
1627
  ...context.sessionId === void 0 ? {} : { sessionId: context.sessionId },
1454
- ...context.cwd === void 0 ? {} : { cwd: context.cwd }
1628
+ ...cwd === void 0 ? {} : {
1629
+ cwd,
1630
+ readableRoots: () => plansRoots(cwd, environment)
1631
+ }
1455
1632
  });
1456
1633
  return {
1457
1634
  fetch: (request) => app.fetch(request),
@@ -1459,6 +1636,20 @@ const api = {
1459
1636
  };
1460
1637
  }
1461
1638
  };
1639
+ /**
1640
+ * The plans directory Plan mode writes to, resolved the way Plan resolves it, so
1641
+ * a plan link in the transcript opens in the file view. A config that cannot be
1642
+ * read admits nothing extra rather than failing the preview.
1643
+ */
1644
+ function plansRoots(cwd, environment) {
1645
+ const home = environment.HOME ?? os.homedir();
1646
+ try {
1647
+ const configured = loadPoConfig(cwd, home, environment).modes?.planning?.plansDirectory;
1648
+ return [resolvePlanningPlansDirectory(configured, cwd, home)];
1649
+ } catch {
1650
+ return [];
1651
+ }
1652
+ }
1462
1653
  //#endregion
1463
1654
  //#region src/services/sessionStateSweep/index.ts
1464
1655
  /**
@@ -1484,7 +1675,7 @@ const STATE_MAX_AGE_MS = 6048e5;
1484
1675
  const TIMELINE_SUFFIX = ".jsonl";
1485
1676
  const SNAPSHOTS_SUFFIX = ".blobs";
1486
1677
  async function remove(target) {
1487
- await fs$1.rm(target, {
1678
+ await fs.rm(target, {
1488
1679
  recursive: true,
1489
1680
  force: true
1490
1681
  });
@@ -1493,7 +1684,7 @@ async function remove(target) {
1493
1684
  async function lastTouched(paths) {
1494
1685
  let newest;
1495
1686
  for (const target of paths) try {
1496
- const stat = await fs$1.stat(target);
1687
+ const stat = await fs.stat(target);
1497
1688
  if (newest === void 0 || stat.mtimeMs > newest) newest = stat.mtimeMs;
1498
1689
  } catch {}
1499
1690
  return newest;
@@ -1503,7 +1694,7 @@ async function sweepSessionState(options) {
1503
1694
  const { directory, keep, removeDirectory = false, now = Date.now(), maxAgeMs = STATE_MAX_AGE_MS } = options;
1504
1695
  let entries;
1505
1696
  try {
1506
- entries = await fs$1.readdir(directory);
1697
+ entries = await fs.readdir(directory);
1507
1698
  } catch {
1508
1699
  return 0;
1509
1700
  }
@@ -1535,20 +1726,21 @@ async function sweepSessionState(options) {
1535
1726
  await remove(orphan);
1536
1727
  }
1537
1728
  if (removeDirectory) try {
1538
- await fs$1.rmdir(directory);
1729
+ await fs.rmdir(directory);
1539
1730
  } catch {}
1540
1731
  return removed;
1541
1732
  }
1542
1733
  //#endregion
1543
1734
  //#region src/services/webFilesChannel/index.ts
1544
1735
  /**
1545
- * The changed-files channel consumes snapshots emitted by the owning session
1546
- * facet. Durable timeline reads seed a channel that starts after session startup.
1736
+ * The changed-files channel consumes snapshots emitted by the owning session's
1737
+ * server plugin. Durable timeline reads seed a channel that starts after session
1738
+ * startup.
1547
1739
  */
1548
1740
  /** Whether the file is still there to be opened. */
1549
1741
  function stillExists(filePath) {
1550
1742
  try {
1551
- return fs.statSync(filePath).isFile();
1743
+ return fs$1.statSync(filePath).isFile();
1552
1744
  } catch {
1553
1745
  return false;
1554
1746
  }
@@ -1566,7 +1758,7 @@ function stillExists(filePath) {
1566
1758
  function readSessionFiles(timelinePath, cwd) {
1567
1759
  let content;
1568
1760
  try {
1569
- content = fs.readFileSync(timelinePath, "utf8");
1761
+ content = fs$1.readFileSync(timelinePath, "utf8");
1570
1762
  } catch {
1571
1763
  return [];
1572
1764
  }
@@ -1580,7 +1772,7 @@ function readSessionFiles(timelinePath, cwd) {
1580
1772
  diffable: isDiffable(foldVersions(events, entry.path))
1581
1773
  }));
1582
1774
  try {
1583
- return filterDoomIgnoredFiles(items, fs.readFileSync(path.join(cwd, ".poignore"), "utf8"));
1775
+ return filterPoIgnoredFiles(items, fs$1.readFileSync(path.join(cwd, ".poignore"), "utf8"));
1584
1776
  } catch {
1585
1777
  return items;
1586
1778
  }
@@ -1636,10 +1828,10 @@ function createFilesChannel() {
1636
1828
  }
1637
1829
  //#endregion
1638
1830
  //#region src/services/fileEditSession/index.ts
1639
- const DOOM_IGNORE_FILE = ".poignore";
1640
- function readDoomIgnore(cwd) {
1831
+ const PO_IGNORE_FILE = ".poignore";
1832
+ function readPoIgnore(cwd) {
1641
1833
  try {
1642
- const matcher = createDoomIgnoreMatcher(fs.readFileSync(path.join(cwd, DOOM_IGNORE_FILE), "utf8"));
1834
+ const matcher = createPoIgnoreMatcher(fs$1.readFileSync(path.join(cwd, PO_IGNORE_FILE), "utf8"));
1643
1835
  return matcher === void 0 ? void 0 : (filePath) => matcher(path.relative(cwd, filePath));
1644
1836
  } catch {
1645
1837
  return;
@@ -1656,10 +1848,10 @@ function createFileEditSession({ agent, host }) {
1656
1848
  const timeline = new TimelineStore();
1657
1849
  const snapshots = new NodeSnapshotStoreAdapter();
1658
1850
  const editTracker = new EditTracker(timeline, snapshots, new NodeTreeManifestAdapter(), { git: new NodeGitStatusAdapter() });
1659
- if (host.context.directEvents === void 0) throw new Error("File-edit headless facet requires the session direct event bus.");
1851
+ if (host.context.directEvents === void 0) throw new Error("File-edit server plugin requires the session direct event bus.");
1660
1852
  const directEvents = host.context.directEvents;
1661
- const telemetry = createDoomTelemetry({
1662
- serviceName: "doom-file-edit",
1853
+ const telemetry = createPoTelemetry({
1854
+ serviceName: "po-file-edit",
1663
1855
  packageName: "@agimon-ai/po-file-edit",
1664
1856
  env: environment,
1665
1857
  enableLogs: true,
@@ -1682,7 +1874,7 @@ function createFileEditSession({ agent, host }) {
1682
1874
  /**
1683
1875
  * The channel carries the rows; the status decides whether the group exists.
1684
1876
  *
1685
- * A cockpit drives this facet and never the Pi runtime, so without the status
1877
+ * A cockpit drives this server plugin and never the Pi runtime, so without the status
1686
1878
  * the dock had the file list on the wire and no frame to draw it in. The empty
1687
1879
  * string is deliberate: the group declares `hideWhenEmpty`, so a session that
1688
1880
  * has edited nothing publishes a key with no content and stays hidden.
@@ -1708,20 +1900,19 @@ function createFileEditSession({ agent, host }) {
1708
1900
  const dispose = () => {
1709
1901
  if (disposal) return disposal;
1710
1902
  closed = true;
1711
- disposal = (async () => {
1712
- try {
1713
- await stopActivity();
1714
- if (lastRuntime) {
1715
- if (!environment[SUBAGENT_CHILD_ENV]) {
1716
- await timeline.clear();
1717
- await snapshots.clear();
1718
- }
1719
- await telemetry.recordEvent("doom_file_edit.timeline_finished", { outcome: "closed" });
1720
- }
1721
- } finally {
1722
- await telemetry.shutdown();
1723
- }
1724
- })();
1903
+ disposal = runCleanups([
1904
+ stopActivity,
1905
+ async () => {
1906
+ if (lastRuntime && !environment[SUBAGENT_CHILD_ENV]) await timeline.clear();
1907
+ },
1908
+ async () => {
1909
+ if (lastRuntime && !environment[SUBAGENT_CHILD_ENV]) await snapshots.clear();
1910
+ },
1911
+ async () => {
1912
+ if (lastRuntime) await telemetry.recordEvent("po_file_edit.timeline_finished", { outcome: "closed" });
1913
+ },
1914
+ () => telemetry.shutdown()
1915
+ ], "File-edit session cleanup failed.");
1725
1916
  return disposal;
1726
1917
  };
1727
1918
  return {
@@ -1739,7 +1930,7 @@ function createFileEditSession({ agent, host }) {
1739
1930
  const snapshotsPath = paths.snapshotsPath(executionContext.cwd, sessionKey);
1740
1931
  timeline.initialize(timelinePath);
1741
1932
  snapshots.initialize(snapshotsPath);
1742
- const isIgnored = readDoomIgnore(executionContext.cwd);
1933
+ const isIgnored = readPoIgnore(executionContext.cwd);
1743
1934
  editTracker.reset({
1744
1935
  exclude: [
1745
1936
  timelinePath,
@@ -1762,16 +1953,11 @@ function createFileEditSession({ agent, host }) {
1762
1953
  directory: paths.stateDirectory(),
1763
1954
  keep: timelinePath
1764
1955
  });
1765
- const legacy = paths.legacyStateDirectory(executionContext.cwd);
1766
- if (legacy !== void 0 && legacy !== paths.stateDirectory()) await sweepSessionState({
1767
- directory: legacy,
1768
- removeDirectory: true
1769
- });
1770
1956
  } catch {}
1771
1957
  });
1772
1958
  if (!initialized) {
1773
1959
  initialized = true;
1774
- await track(() => telemetry.recordEvent("doom_file_edit.timeline_started", { outcome: "initialized" }));
1960
+ await track(() => telemetry.recordEvent("po_file_edit.timeline_started", { outcome: "initialized" }));
1775
1961
  }
1776
1962
  return () => active === runtime ? stopActivity() : Promise.resolve();
1777
1963
  }
@@ -1788,7 +1974,7 @@ function createFileEditSession({ agent, host }) {
1788
1974
  const hookStartedAt = Date.now();
1789
1975
  await editTracker.start(toolCallId, toolName, event.args, executionContext.cwd);
1790
1976
  if (active !== runtime) return;
1791
- await telemetry.recordEvent("doom_file_edit.edit_started", {
1977
+ await telemetry.recordEvent("po_file_edit.edit_started", {
1792
1978
  "tool.name": toolName,
1793
1979
  hook_ms: Date.now() - hookStartedAt
1794
1980
  });
@@ -1809,7 +1995,7 @@ function createFileEditSession({ agent, host }) {
1809
1995
  await editTracker.end(toolCallId, isError, executionContext.cwd);
1810
1996
  if (active !== runtime) return;
1811
1997
  publish(runtime);
1812
- await telemetry.recordEvent("doom_file_edit.edit_finished", {
1998
+ await telemetry.recordEvent("po_file_edit.edit_finished", {
1813
1999
  "tool.name": toolName,
1814
2000
  "tool.result.error": isError,
1815
2001
  ...startedAt === void 0 ? {} : { duration_ms: Date.now() - startedAt },
@@ -1824,12 +2010,38 @@ function createFileEditSession({ agent, host }) {
1824
2010
  }
1825
2011
  //#endregion
1826
2012
  //#region src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts
2013
+ function createPresence({ agent, host, context }) {
2014
+ const ledger = host.context.editPresence;
2015
+ if (!agent || !ledger) return void 0;
2016
+ const execution = agent.context;
2017
+ return createSessionPresence({
2018
+ ledger,
2019
+ sessionId: execution.sessionId,
2020
+ cwd: execution.cwd,
2021
+ workflowStep: execution.sessionContext?.provenance === WORKFLOW_STEP_SESSION_PROVENANCE,
2022
+ isLive: () => host.context.sessionService?.isLive(execution.sessionId) ?? true,
2023
+ labelFor: (runId) => readPoChildSessionLabels(context)?.labelFor(runId),
2024
+ onError: (error) => host.context.onNotice(`Edit presence failed: ${error instanceof Error ? error.message : String(error)}`)
2025
+ });
2026
+ }
1827
2027
  const createFileEditServerRoot = (context) => {
1828
2028
  const runtime = createFileEditSession(context);
2029
+ const presence = createPresence(context);
1829
2030
  return {
1830
- value: runtime,
2031
+ value: {
2032
+ ...runtime,
2033
+ ...presence === void 0 ? {} : { presence }
2034
+ },
1831
2035
  activities: runtime.activities,
1832
- onDispose: runtime.onDispose
2036
+ ...presence === void 0 ? {} : { services: [(cordis) => {
2037
+ cordis.inject([PO_CHILD_SESSION_HOOK_REGISTRY_SERVICE], (registryContext) => {
2038
+ const registration = registryContext.get(PO_CHILD_SESSION_HOOK_REGISTRY_SERVICE).register(presence.childHooks);
2039
+ return () => registration.dispose();
2040
+ });
2041
+ }] },
2042
+ onDispose: async (pluginContext) => {
2043
+ await runCleanups([() => presence?.dispose(), () => runtime.onDispose?.(pluginContext)], "File-edit root cleanup failed.");
2044
+ }
1833
2045
  };
1834
2046
  };
1835
2047
  //#endregion
@@ -1858,23 +2070,32 @@ var tool_execution_end_server_default = defineHook((context) => context.root.hoo
1858
2070
  //#region src/extensions/workspaces/sessions/(backend)/hook/tool-execution-start.server.ts
1859
2071
  var tool_execution_start_server_default = defineHook((context) => context.root.hooks[0]);
1860
2072
  //#endregion
2073
+ //#region src/extensions/workspaces/sessions/(backend)/hook/tool-result.server.ts
2074
+ var tool_result_server_default = defineHook(({ root }) => ({
2075
+ event: "tool_result",
2076
+ handle: async (event, execution) => root.presence?.toolResult.handle(event, execution)
2077
+ }));
2078
+ //#endregion
1861
2079
  //#region generated/server.ts
1862
2080
  const at = (value, context) => typeof value === "function" ? value(context) : value;
2081
+ /* v8 ignore next -- generated normalization branch */
2082
+ const defined = (values) => values.filter((value) => value !== void 0);
1863
2083
  const via = (identity, value) => value === void 0 ? void 0 : {
1864
2084
  ...identity,
1865
2085
  ...value
1866
2086
  };
1867
- const facet = defineServerPlugin({
2087
+ const serverPlugin = defineServerPlugin({
1868
2088
  name: "@agimon-ai/po-file-edit",
1869
2089
  providerOverrides: composeServerProviderOverrides(root_server_default),
2090
+ injects: composeRootInjects({ session: [root_server_default] }),
1870
2091
  global: () => ({ get channels() {
1871
2092
  return [() => via({ frameType: "file_edits" }, fileEdits_server_default())];
1872
2093
  } }),
1873
- workspace: () => ({ get channels() {
1874
- return [() => via({ frameType: "file_edits" }, fileEdits_server_default())];
2094
+ workspace: (context) => ({ get channels() {
2095
+ return defined([context.hosted("global") ? void 0 : () => via({ frameType: "file_edits" }, fileEdits_server_default())]);
1875
2096
  } }),
1876
2097
  session: async (context) => {
1877
- const scopeSession = await root_server_default(context);
2098
+ const scopeSession = await context.scopeRoot("session", root_server_default, context);
1878
2099
  const contextSession = {
1879
2100
  ...context,
1880
2101
  root: scopeSession.value
@@ -1885,10 +2106,14 @@ const facet = defineServerPlugin({
1885
2106
  return [at(route_server_default, contextSession)];
1886
2107
  },
1887
2108
  get channels() {
1888
- return [() => via({ frameType: "file_edits" }, fileEdits_server_default())];
2109
+ return defined([context.hosted("global") ? void 0 : () => via({ frameType: "file_edits" }, fileEdits_server_default())]);
1889
2110
  },
1890
2111
  get hooks() {
1891
- 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))];
2112
+ return [
2113
+ via({ event: "tool_execution_end" }, at(tool_execution_end_server_default, contextSession)),
2114
+ via({ event: "tool_execution_start" }, at(tool_execution_start_server_default, contextSession)),
2115
+ via({ event: "tool_result" }, at(tool_result_server_default, contextSession))
2116
+ ];
1892
2117
  },
1893
2118
  services: [...scopeSession.services ?? []],
1894
2119
  get activities() {
@@ -1898,6 +2123,6 @@ const facet = defineServerPlugin({
1898
2123
  }
1899
2124
  });
1900
2125
  //#endregion
1901
- export { facet as default, facet };
2126
+ export { serverPlugin as default, serverPlugin };
1902
2127
 
1903
2128
  //# sourceMappingURL=server.mjs.map