openwolf-enhanced 1.26.0 → 1.28.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +306 -0
  2. package/dist/dashboard/assets/{AISuggestions-DZmzksSA.js → AISuggestions-C7WFvzLO.js} +1 -1
  3. package/dist/dashboard/assets/{ActivityLog-BJ0omuzu.js → ActivityLog-SsRbpBFt.js} +1 -1
  4. package/dist/dashboard/assets/{ActivityTimeline-BWhxretI.js → ActivityTimeline-BoV30nDY.js} +1 -1
  5. package/dist/dashboard/assets/{AnatomyBrowser-DNVp6-_r.js → AnatomyBrowser-DwzsHn4z.js} +1 -1
  6. package/dist/dashboard/assets/{BugLog-DMJTuU9p.js → BugLog-CPl4Q-JG.js} +1 -1
  7. package/dist/dashboard/assets/{CerebrumViewer-BwcGr6pu.js → CerebrumViewer-_f6fBsNF.js} +1 -1
  8. package/dist/dashboard/assets/{CronStatus-Dwf_M4-S.js → CronStatus-1iBEPJK5.js} +1 -1
  9. package/dist/dashboard/assets/{DesignQC-DzHkh94I.js → DesignQC-D4UU7lca.js} +1 -1
  10. package/dist/dashboard/assets/{LinkGraph-kqQD--Yj.js → LinkGraph-BWtI8Gh3.js} +1 -1
  11. package/dist/dashboard/assets/{MemoryViewer-BCn9Fr-w.js → MemoryViewer-BYRVG34g.js} +1 -1
  12. package/dist/dashboard/assets/{NativeMemory-DPMZaP5E.js → NativeMemory-Dphtrqjt.js} +1 -1
  13. package/dist/dashboard/assets/{ProjectOverview-PWENJiC4.js → ProjectOverview-CTYohvjD.js} +1 -1
  14. package/dist/dashboard/assets/{ProjectsAggregate-Baj44DVk.js → ProjectsAggregate-CMxNs35H.js} +1 -1
  15. package/dist/dashboard/assets/{StatusBadge-Db61D51H.js → StatusBadge-CjrFCDOI.js} +1 -1
  16. package/dist/dashboard/assets/{TokenUsage-UefDh9w4.js → TokenUsage-C5wJpMl8.js} +1 -1
  17. package/dist/dashboard/assets/index-BNi8lsJE.css +2 -0
  18. package/dist/dashboard/assets/{index-e2PfOI0b.js → index-C74qneS9.js} +5 -5
  19. package/dist/dashboard/index.html +2 -2
  20. package/dist/hooks/post-bash.js +10 -5
  21. package/dist/hooks/post-read.js +24 -26
  22. package/dist/hooks/post-write.js +135 -118
  23. package/dist/hooks/pre-read.js +42 -36
  24. package/dist/hooks/pre-write.js +3 -3
  25. package/dist/hooks/precompact.js +2 -2
  26. package/dist/hooks/session-start.js +15 -6
  27. package/dist/hooks/shared.js +221 -14
  28. package/dist/hooks/stop.js +33 -7
  29. package/dist/src/cli/cron-cmd.js +2 -2
  30. package/dist/src/cli/cron-cmd.js.map +1 -1
  31. package/dist/src/cli/daemon-cmd.js +49 -21
  32. package/dist/src/cli/daemon-cmd.js.map +1 -1
  33. package/dist/src/cli/doctor-cmd.js +2 -1
  34. package/dist/src/cli/doctor-cmd.js.map +1 -1
  35. package/dist/src/cli/export-cmd.js +8 -0
  36. package/dist/src/cli/export-cmd.js.map +1 -1
  37. package/dist/src/cli/init.js +34 -8
  38. package/dist/src/cli/init.js.map +1 -1
  39. package/dist/src/cli/registry.js +68 -33
  40. package/dist/src/cli/registry.js.map +1 -1
  41. package/dist/src/cli/update.js +37 -6
  42. package/dist/src/cli/update.js.map +1 -1
  43. package/dist/src/daemon/cron-engine.js +70 -8
  44. package/dist/src/daemon/cron-engine.js.map +1 -1
  45. package/dist/src/daemon/file-watcher.js +4 -1
  46. package/dist/src/daemon/file-watcher.js.map +1 -1
  47. package/dist/src/daemon/llm-provider.js +9 -6
  48. package/dist/src/daemon/llm-provider.js.map +1 -1
  49. package/dist/src/daemon/wolf-daemon.js +69 -15
  50. package/dist/src/daemon/wolf-daemon.js.map +1 -1
  51. package/dist/src/designqc/designqc-capture.js +116 -6
  52. package/dist/src/designqc/designqc-capture.js.map +1 -1
  53. package/dist/src/designqc/designqc-engine.js +9 -3
  54. package/dist/src/designqc/designqc-engine.js.map +1 -1
  55. package/dist/src/hooks/post-bash.js +10 -5
  56. package/dist/src/hooks/post-bash.js.map +1 -1
  57. package/dist/src/hooks/post-read.js +24 -26
  58. package/dist/src/hooks/post-read.js.map +1 -1
  59. package/dist/src/hooks/post-write.js +135 -118
  60. package/dist/src/hooks/post-write.js.map +1 -1
  61. package/dist/src/hooks/pre-read.js +42 -36
  62. package/dist/src/hooks/pre-read.js.map +1 -1
  63. package/dist/src/hooks/pre-write.js +3 -3
  64. package/dist/src/hooks/pre-write.js.map +1 -1
  65. package/dist/src/hooks/precompact.js +2 -2
  66. package/dist/src/hooks/precompact.js.map +1 -1
  67. package/dist/src/hooks/session-start.js +15 -6
  68. package/dist/src/hooks/session-start.js.map +1 -1
  69. package/dist/src/hooks/shared.js +221 -14
  70. package/dist/src/hooks/shared.js.map +1 -1
  71. package/dist/src/hooks/stop.js +33 -7
  72. package/dist/src/hooks/stop.js.map +1 -1
  73. package/dist/src/scanner/anatomy-scanner.js +7 -3
  74. package/dist/src/scanner/anatomy-scanner.js.map +1 -1
  75. package/dist/src/utils/agent-hooks.js +50 -4
  76. package/dist/src/utils/agent-hooks.js.map +1 -1
  77. package/dist/src/utils/daemon-pid.js +56 -0
  78. package/dist/src/utils/daemon-pid.js.map +1 -0
  79. package/dist/src/utils/dashboard-auth.js +13 -1
  80. package/dist/src/utils/dashboard-auth.js.map +1 -1
  81. package/dist/src/utils/embeddings.js +9 -1
  82. package/dist/src/utils/embeddings.js.map +1 -1
  83. package/dist/src/utils/fs-safe.js +69 -11
  84. package/dist/src/utils/fs-safe.js.map +1 -1
  85. package/dist/src/utils/logger.js +21 -6
  86. package/dist/src/utils/logger.js.map +1 -1
  87. package/dist/src/utils/maintenance.js +100 -6
  88. package/dist/src/utils/maintenance.js.map +1 -1
  89. package/dist/src/utils/platform.js +39 -0
  90. package/dist/src/utils/platform.js.map +1 -1
  91. package/dist/src/utils/remote.js +2 -2
  92. package/dist/src/utils/remote.js.map +1 -1
  93. package/package.json +1 -1
  94. package/src/templates/config.json +34 -9
  95. package/dist/dashboard/assets/index-CVfvTpSA.css +0 -2
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort, buildResumeDigest, readStdin, withLock, SESSION_SUMMARY_SCAFFOLD, bookInjection } from "./shared.js";
3
+ import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort, buildResumeDigest, readStdin, tryWithLock, sessionFileFor, pruneOldSessionFiles, SESSION_SUMMARY_SCAFFOLD, bookInjection } from "./shared.js";
4
4
  import { estimateTokens, getTokenRatios } from "./token-estimator.js";
5
5
  async function main() {
6
6
  ensureWolfDir();
@@ -19,7 +19,6 @@ async function main() {
19
19
  }
20
20
  catch { }
21
21
  const hooksDir = path.join(wolfDir, "hooks");
22
- const sessionFile = path.join(hooksDir, "_session.json");
23
22
  const now = new Date();
24
23
  const sessionId = `session-${now.toISOString().slice(0, 10)}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
25
24
  // SessionStart fires for startup/resume/clear/compact. Only startup and clear begin a genuinely
@@ -27,16 +26,26 @@ async function main() {
27
26
  // would wipe this session's read/write tracking mid-flight and append a spurious memory header
28
27
  // on every compaction (compaction survival).
29
28
  let source = "startup";
29
+ let harnessSessionId;
30
30
  try {
31
31
  const hookInput = JSON.parse(await readStdin());
32
32
  if (typeof hookInput.source === "string")
33
33
  source = hookInput.source;
34
+ if (typeof hookInput.session_id === "string")
35
+ harnessSessionId = hookInput.session_id;
34
36
  }
35
37
  catch { }
38
+ // One state file per session (see sessionFileFor). Two agents in the same project used to share
39
+ // one, so the second one's startup wiped the first one's in-flight tracking.
40
+ const sessionFile = sessionFileFor(hooksDir, harnessSessionId);
41
+ // One file per session accumulates; a finished session is already folded into the ledger.
42
+ pruneOldSessionFiles(hooksDir);
36
43
  const continuing = (source === "compact" || source === "resume") && fs.existsSync(sessionFile);
37
44
  if (!continuing) {
38
- // Create fresh session state
39
- writeJSON(sessionFile, {
45
+ // Create fresh session state. Under the lock like every other writer of this file: the reset is
46
+ // a whole-file replacement, so an unlocked write here could land in the middle of another
47
+ // hook's read-modify-write and hand it a half-written file to parse.
48
+ tryWithLock(sessionFile, () => writeJSON(sessionFile, {
40
49
  session_id: sessionId,
41
50
  started: timestamp(),
42
51
  files_read: {},
@@ -47,7 +56,7 @@ async function main() {
47
56
  repeated_reads_warned: 0,
48
57
  cerebrum_warnings: 0,
49
58
  stop_count: 0,
50
- });
59
+ }));
51
60
  // Append session header to memory.md
52
61
  const memoryPath = path.join(wolfDir, "memory.md");
53
62
  const header = `\n## Session: ${now.toISOString().slice(0, 10)} ${timeShort()}\n\n${SESSION_SUMMARY_SCAFFOLD}\n\n| Time | Action | File(s) | Outcome | ~Tokens |\n|------|--------|---------|---------|--------|\n`;
@@ -87,7 +96,7 @@ async function main() {
87
96
  // Same lock as the stop hook's ledger write — a session ending in one process while another
88
97
  // starts must not clobber the read-modify-write.
89
98
  const ledgerPath = path.join(wolfDir, "token-ledger.json");
90
- withLock(ledgerPath, () => {
99
+ tryWithLock(ledgerPath, () => {
91
100
  const ledger = readJSON(ledgerPath, { version: 1, lifetime: { total_sessions: 0 } });
92
101
  ledger.lifetime.total_sessions++;
93
102
  writeJSON(ledgerPath, ledger);
@@ -74,10 +74,31 @@ export function ensureWolfDir() {
74
74
  process.exit(0);
75
75
  }
76
76
  }
77
- // Best-effort advisory lock around a read-modify-write cycle so concurrent hook/daemon
78
- // processes don't clobber each other's updates to the same file (M1). It NEVER blocks a hook
79
- // for long: it waits up to ~1s for the lock, steals a stale lock (>5s old), and if it still
80
- // can't acquire it, runs unlocked rather than risk a hook timeout.
77
+ // Advisory lock around a read-modify-write cycle so concurrent hook/daemon processes don't clobber
78
+ // each other's updates to the same file (M1). It waits, steals a lock left behind by a dead process
79
+ // (>5s old), and if it still cannot acquire — FAILS instead of proceeding.
80
+ //
81
+ // [2026-08-31] It used to run the callback unlocked after the wait expired, which defeats the lock
82
+ // for every writer that respects it: the whole point is that exactly one process is inside the
83
+ // critical section, and a participant that proceeds anyway makes the guarantee vacuous for
84
+ // everybody else. Under contention that is not a rare fallback, it is the common path — contention
85
+ // is precisely when the timeout fires. Failing closed loses one update; proceeding unlocked loses
86
+ // somebody else's, and can hand a half-written file to the next reader.
87
+ //
88
+ // The poll also started at a flat 25ms. With seven hooks around one file, a herd needs several
89
+ // rounds to drain and each waiter burned 25ms per attempt whether or not the lock was free a
90
+ // microsecond later. It now starts at 2ms and backs off, so the common case (a lock held for the
91
+ // microseconds of one write) is picked up almost immediately and a herd drains inside the budget.
92
+ export class LockTimeoutError extends Error {
93
+ targetPath;
94
+ waitedMs;
95
+ constructor(targetPath, waitedMs) {
96
+ super(`could not acquire the lock on ${path.basename(targetPath)} within ${waitedMs}ms — update skipped`);
97
+ this.targetPath = targetPath;
98
+ this.waitedMs = waitedMs;
99
+ this.name = "LockTimeoutError";
100
+ }
101
+ }
81
102
  function sleepSync(ms) {
82
103
  try {
83
104
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -86,10 +107,11 @@ function sleepSync(ms) {
86
107
  }
87
108
  export function withLock(targetPath, fn) {
88
109
  const lockPath = targetPath + ".lock";
89
- const MAX_WAIT_MS = 1000;
110
+ const MAX_WAIT_MS = 1500; // well inside the tightest hook budget (5s)
90
111
  const STALE_MS = 5000;
91
112
  const start = Date.now();
92
113
  let held = false;
114
+ let backoff = 2;
93
115
  while (Date.now() - start < MAX_WAIT_MS) {
94
116
  try {
95
117
  const fd = fs.openSync(lockPath, "wx");
@@ -111,21 +133,115 @@ export function withLock(targetPath, fn) {
111
133
  catch {
112
134
  continue;
113
135
  } // lock vanished — retry immediately
114
- sleepSync(25);
136
+ sleepSync(backoff);
137
+ backoff = Math.min(backoff * 2, 50);
115
138
  }
116
139
  }
140
+ if (!held)
141
+ throw new LockTimeoutError(targetPath, Date.now() - start);
117
142
  try {
118
143
  return fn();
119
144
  }
120
145
  finally {
121
- if (held) {
122
- try {
123
- fs.unlinkSync(lockPath);
124
- }
125
- catch { /* ignore */ }
146
+ try {
147
+ fs.unlinkSync(lockPath);
148
+ }
149
+ catch { /* ignore */ }
150
+ }
151
+ }
152
+ /**
153
+ * withLock for callers that must never throw — a hook that dies takes the tool call with it.
154
+ * Returns whether the callback ran; a lost update is reported on stderr rather than silently
155
+ * dropped, because "the number is wrong and nobody knows why" is the failure this whole lock
156
+ * exists to prevent.
157
+ */
158
+ export function tryWithLock(targetPath, fn) {
159
+ try {
160
+ withLock(targetPath, fn);
161
+ return true;
162
+ }
163
+ catch (e) {
164
+ if (e instanceof LockTimeoutError) {
165
+ process.stderr.write(`[openwolf] ${e.message}\n`);
166
+ return false;
126
167
  }
168
+ throw e;
127
169
  }
128
170
  }
171
+ /**
172
+ * Where this session's tracking state lives.
173
+ *
174
+ * There used to be exactly one `_session.json` per project, shared by every session running in it.
175
+ * Two agents in the same repo — two terminals, or an agent plus a scripted run — meant the second
176
+ * one's SessionStart reset the first one's tracking mid-flight, and from then on both wrote into
177
+ * the same file: reads counted against the wrong session, the ledger entry for the first one
178
+ * ending with whatever the second had accumulated.
179
+ *
180
+ * Every harness hands each hook invocation a `session_id`, so the state is keyed by it. When there
181
+ * is none (an agent that does not provide one), the legacy shared path is used exactly as before —
182
+ * no worse than it was, and consistent within that agent.
183
+ */
184
+ export function sessionFileFor(hooksDir, sessionId) {
185
+ if (typeof sessionId === "string") {
186
+ // Same reasoning as the cron task slug: this string becomes a file name and arrives from
187
+ // outside, so it is reduced to something that can only ever name a file in this directory.
188
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 64);
189
+ if (safe)
190
+ return path.join(hooksDir, `_session-${safe}.json`);
191
+ }
192
+ return path.join(hooksDir, "_session.json");
193
+ }
194
+ /**
195
+ * Delete per-session state files nobody will read again.
196
+ *
197
+ * One file per session means the directory grows with every session, forever. The stop hook has
198
+ * already folded a finished session into token-ledger.json, so the file is spent; a week is
199
+ * generous room for a session that is somehow still live. The legacy shared `_session.json` is
200
+ * never removed — it has no session it belongs to, so age says nothing about it.
201
+ */
202
+ export function pruneOldSessionFiles(hooksDir, maxAgeDays = 7) {
203
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
204
+ let names;
205
+ try {
206
+ names = fs.readdirSync(hooksDir);
207
+ }
208
+ catch {
209
+ return;
210
+ }
211
+ for (const name of names) {
212
+ if (!/^_session-.+\.json(\.lock)?$/.test(name))
213
+ continue;
214
+ const f = path.join(hooksDir, name);
215
+ try {
216
+ if (fs.statSync(f).mtimeMs < cutoff)
217
+ fs.unlinkSync(f);
218
+ }
219
+ catch { /* vanished or unreadable — nothing to clean */ }
220
+ }
221
+ }
222
+ /**
223
+ * Read-modify-write `_session.json` under the lock, in one place.
224
+ *
225
+ * Every hook in a session mutates this one file, and until now only two of the seven writers took
226
+ * the lock at all: the rest did `readJSON` … `writeJSON` around unrelated work, so a PostToolUse
227
+ * hook finishing while PreToolUse was mid-cycle silently dropped one of the two updates. Reads and
228
+ * writes get counted for the token ledger, so a lost update is a wrong number in a report nobody
229
+ * can audit afterwards.
230
+ *
231
+ * The mutator receives the state as it is ON DISK at the moment the lock is held — not a snapshot
232
+ * taken earlier — and must apply a DELTA (increment, add a key), never assign a whole object it
233
+ * captured before. That is what makes the update safe against a concurrent writer.
234
+ */
235
+ export function updateSession(sessionFile, fallback, mutate) {
236
+ // tryWithLock, not withLock: a hook that throws takes the tool call with it. Under contention
237
+ // the update is dropped and SAID SO on stderr — the alternative (writing anyway) is what made
238
+ // the lock decorative in the first place.
239
+ return tryWithLock(sessionFile, () => {
240
+ const session = readJSON(sessionFile, fallback);
241
+ mutate(session);
242
+ writeJSON(sessionFile, session);
243
+ });
244
+ }
129
245
  // A missing file is normal (first run) and silently yields the fallback. An EXISTING file that
130
246
  // fails to parse is a different situation: whatever it held is about to be treated as empty, and
131
247
  // a read-modify-write caller would then overwrite it with defaults. That must at least be visible.
@@ -145,7 +261,7 @@ export function bookInjection(wolfDir, tokens) {
145
261
  return;
146
262
  const ledgerPath = path.join(wolfDir, "token-ledger.json");
147
263
  try {
148
- withLock(ledgerPath, () => {
264
+ tryWithLock(ledgerPath, () => {
149
265
  // [2026-08-20, review] The first cut used readJSON with a two-field fallback and then wrote
150
266
  // it back. On an UNPARSEABLE ledger that produced a valid-looking file with no `sessions[]`,
151
267
  // and the stop hook died on `ledger.sessions.findIndex(...)` from then on — silently, because
@@ -201,10 +317,24 @@ export function readJSON(filePath, fallback) {
201
317
  return JSON.parse(raw);
202
318
  }
203
319
  catch (e) {
204
- process.stderr.write(`[openwolf] ${path.basename(filePath)} exists but is not valid JSON using defaults (${e.message})\n`);
320
+ // The caller is about to treat this file as empty and, in a read-modify-write, overwrite it
321
+ // with defaults — so a corrupt token-ledger.json meant months of usage history vanished on the
322
+ // next Stop hook, with one line on stderr as the only trace. Move it aside first. Recovery is
323
+ // then a possibility rather than a hope, and because the path no longer exists, the next read
324
+ // takes the ordinary "missing file" branch instead of warning forever.
325
+ quarantineCorrupt(filePath);
326
+ process.stderr.write(`[openwolf] ${path.basename(filePath)} was not valid JSON — moved aside, using defaults (${e.message})\n`);
205
327
  return fallback;
206
328
  }
207
329
  }
330
+ /** Rename a corrupt file to <name>.corrupt-<stamp>. Best-effort: never throws, never blocks a read. */
331
+ function quarantineCorrupt(filePath) {
332
+ try {
333
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
334
+ fs.renameSync(filePath, `${filePath}.corrupt-${stamp}`);
335
+ }
336
+ catch { /* unwritable directory, race with another process — defaults still apply */ }
337
+ }
208
338
  // Duplicated from utils/fs-safe.ts (separate build roots). Never throws — a hook must not kill a
209
339
  // session over a failed journal write — but a total failure is reported on stderr instead of
210
340
  // vanishing: a silently dropped write once hid a completely broken persistence path (bug-183).
@@ -298,12 +428,17 @@ export function makeIgnoreMatcher(patterns) {
298
428
  if (pat.startsWith("*.") && norm.endsWith(pat.slice(1)))
299
429
  return true;
300
430
  }
301
- if (pat.includes("*")) {
431
+ // `?` is a gitignore wildcard for exactly one character, and it also has to be handled BEFORE
432
+ // this becomes a regex — it used to be neither escaped nor translated, so it survived into the
433
+ // pattern as a regex quantifier and made the preceding character OPTIONAL. `build?/*.js` then
434
+ // matched `buil/x.js` and missed `build2/x.js`: the rule silently covered the wrong files.
435
+ if (pat.includes("*") || pat.includes("?")) {
302
436
  const re = new RegExp("^" +
303
437
  pat
304
438
  .replace(/[.+^${}()|[\]\\]/g, "\\$&")
305
439
  .replace(/\*\*/g, "\u0000")
306
440
  .replace(/\*/g, "[^/]*")
441
+ .replace(/\?/g, "[^/]")
307
442
  .replace(/\u0000/g, ".*") +
308
443
  "$");
309
444
  if (re.test(norm) || (!anchored && re.test(base)))
@@ -1054,6 +1189,78 @@ export function readStdin() {
1054
1189
  export function normalizePath(p) {
1055
1190
  return p.replace(/\\/g, "/");
1056
1191
  }
1192
+ /**
1193
+ * The file's path relative to the project root, or "" when it is outside.
1194
+ *
1195
+ * Lexical first — cheap, and correct for the overwhelming majority. But a lexical comparison alone
1196
+ * silently drops a whole project whenever the root is reached through a symlink: /home/me/work is
1197
+ * a link to /mnt/data/work, the agent opens /mnt/data/work/src/a.ts, the prefix does not match, and
1198
+ * every read in that project goes untracked with no error anywhere. macOS makes this the default
1199
+ * case for anything under /tmp (-> /private/tmp).
1200
+ *
1201
+ * So a lexical miss gets a second opinion from realpath, on both sides. Resolution only runs when
1202
+ * the cheap answer already said no, so the common path costs nothing.
1203
+ */
1204
+ export function relativeToProject(absFile, projectDir) {
1205
+ const file = normalizePath(absFile);
1206
+ const root = normalizePath(projectDir);
1207
+ // Require the SEPARATOR after the root — a bare startsWith also matched a sibling directory
1208
+ // sharing the prefix (/root/orderflow2 counted as inside /root/orderflow).
1209
+ if (file.startsWith(root + "/"))
1210
+ return file.slice(root.length + 1);
1211
+ if (file === root)
1212
+ return "";
1213
+ try {
1214
+ const realFile = normalizePath(fs.realpathSync(absFile));
1215
+ const realRoot = normalizePath(fs.realpathSync(projectDir));
1216
+ if (realFile.startsWith(realRoot + "/"))
1217
+ return realFile.slice(realRoot.length + 1);
1218
+ }
1219
+ catch { /* the file may not exist yet, or the root is gone — outside, then */ }
1220
+ return "";
1221
+ }
1222
+ /**
1223
+ * Does an absolute (forward-slashed) file path correspond to this anatomy.md entry?
1224
+ *
1225
+ * anatomy entries are project-relative ("src/api/client.ts"); the hooks see an absolute path. The
1226
+ * old test was `abs.endsWith(rel) || abs.endsWith("/" + rel)`, and the first half of that had no
1227
+ * path-segment boundary: `/home/me/vendor/src/api/client.ts` and `/tmp/other-src/api/client.ts`
1228
+ * both "matched" an entry for `src/api/client.ts`, so the hook printed another file's description
1229
+ * and token estimate and booked an anatomy HIT for a file anatomy.md had never seen. The second
1230
+ * half of the old expression was already correct, so this keeps that and drops the loose one — an
1231
+ * exact equality covers the case where the path is already relative.
1232
+ */
1233
+ export function matchesAnatomyEntry(filePath, entryRelPath) {
1234
+ return filePath === entryRelPath || filePath.endsWith("/" + entryRelPath);
1235
+ }
1236
+ /**
1237
+ * Does this project-relative path point OUTSIDE the project root?
1238
+ *
1239
+ * Input is the output of `normalizePath(path.relative(root, file))`. The obvious test —
1240
+ * "does it start with `..`" — is complete on POSIX and wrong on Windows, because `path.relative`
1241
+ * cannot express a route between two drives and gives up by returning the target ABSOLUTE:
1242
+ *
1243
+ * path.win32.relative("C:\\proj", "F:\\secrets\\x") === "F:\\secrets\\x"
1244
+ *
1245
+ * That has no leading `..`, so the guard passed it and the full foreign path was written into
1246
+ * anatomy.md and memory.md — precisely the leak (upstream #56) the guard exists to prevent, on the
1247
+ * one platform nobody tested it on. An absolute result means "no relative route exists", i.e. the
1248
+ * strongest possible outside-the-root signal.
1249
+ *
1250
+ * The `..` test is also anchored to a whole segment now: a file genuinely named `..config` in the
1251
+ * root is inside the project, and used to be counted as external.
1252
+ */
1253
+ export function isOutsideProject(relPath) {
1254
+ if (relPath === "")
1255
+ return true; // the path IS the root, not a file in it
1256
+ if (relPath === ".." || relPath.startsWith("../"))
1257
+ return true;
1258
+ if (path.isAbsolute(relPath))
1259
+ return true; // win32 cross-drive ("F:/secrets/x")
1260
+ if (/^[a-zA-Z]:/.test(relPath))
1261
+ return true; // drive-relative ("F:x"), also not ours
1262
+ return false;
1263
+ }
1057
1264
  // Count non-mechanical (semantic) entries written to memory.md today. Mechanical rows
1058
1265
  // (auto file-op / session-end lines) don't count. Used by the stop hook to detect whether
1059
1266
  // a meaningful session summary was written. (upstream #55)
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getWolfDir, ensureWolfDir, readJSON, writeJSON, appendMarkdown, timeShort, getRetention, compactMemoryIfLarge, countSemanticEntries, withLock, readStdin, readTranscriptUsage, detectAgent, bookInjection } from "./shared.js";
3
+ import { getWolfDir, ensureWolfDir, readJSON, writeJSON, appendMarkdown, timeShort, getRetention, compactMemoryIfLarge, countSemanticEntries, tryWithLock, updateSession, sessionFileFor, readStdin, readTranscriptUsage, detectAgent, bookInjection } from "./shared.js";
4
4
  import { estimateTokens, getTokenRatios } from "./token-estimator.js";
5
5
  const NOTHING_BOOKED = {
6
6
  reads: 0, writes: 0, tokens: 0, anatomy_hits: 0, anatomy_misses: 0, repeated_reads_blocked: 0,
@@ -18,13 +18,16 @@ async function main() {
18
18
  ensureWolfDir();
19
19
  const wolfDir = getWolfDir();
20
20
  const hooksDir = path.join(wolfDir, "hooks");
21
- const sessionFile = path.join(hooksDir, "_session.json");
22
21
  // Stop payload carries the harness transcript path — the source of real, measured token usage.
23
22
  let hookInput = {};
24
23
  try {
25
24
  hookInput = JSON.parse(await readStdin());
26
25
  }
27
26
  catch { }
27
+ // Must be resolved from the payload, like every other hook — this hook is what folds the
28
+ // session into the ledger, so reading a different session's file here would attribute one
29
+ // session's whole turn to another.
30
+ const sessionFile = sessionFileFor(hooksDir, hookInput.session_id);
28
31
  const session = readJSON(sessionFile, {
29
32
  session_id: "",
30
33
  started: "",
@@ -49,7 +52,11 @@ async function main() {
49
52
  const unnamedWrites = externalWrites + bashWrites;
50
53
  const totalWrites = writeCount + unnamedWrites;
51
54
  if (readCount === 0 && totalWrites === 0) {
52
- writeJSON(sessionFile, session);
55
+ // Only stop_count moved; apply it as a delta so an idle turn cannot overwrite a sibling
56
+ // hook's concurrent update with this hook's stale snapshot.
57
+ updateSession(sessionFile, session, (s) => {
58
+ s.stop_count = Math.max(s.stop_count ?? 0, session.stop_count);
59
+ });
53
60
  process.exit(0);
54
61
  return;
55
62
  }
@@ -110,7 +117,10 @@ async function main() {
110
117
  // token report don't clobber each other (M1).
111
118
  const ret = getRetention(wolfDir);
112
119
  const ledgerPath = path.join(wolfDir, "token-ledger.json");
113
- withLock(ledgerPath, () => {
120
+ // A contended ledger write is skipped, not forced: this hook's numbers are cumulative and the
121
+ // next Stop re-books the same deltas, so one skipped turn costs nothing. Forcing it would
122
+ // overwrite whichever process is inside the critical section right now.
123
+ tryWithLock(ledgerPath, () => {
114
124
  const ledger = readJSON(ledgerPath, {
115
125
  version: 1,
116
126
  created_at: "",
@@ -180,8 +190,15 @@ async function main() {
180
190
  lt.real_cache_creation_tokens = (lt.real_cache_creation_tokens ?? 0) + delta(realUsage.cache_creation_input_tokens, booked.real_cache_creation);
181
191
  lt.real_api_calls = (lt.real_api_calls ?? 0) + delta(realUsage.api_calls, booked.real_api_calls);
182
192
  }
183
- // Mark what is now booked. Set inside the lock, right after the numbers went in, so a crash
184
- // between the two writes cannot leave the ledger credited and the session unmarked.
193
+ // Mark what is now booked.
194
+ //
195
+ // [fix] Setting this field inside the lock was never the point — PERSISTING it was. The write to
196
+ // _session.json used to sit ~30 lines further down, past two memory.md appends and a memory
197
+ // compaction, outside this lock. Anything that ended the process in that window (a hook timeout,
198
+ // the user quitting, a full disk during compactMemoryIfLarge) left the ledger credited and the
199
+ // session file still carrying the OLD booked values, so the next Stop re-booked every delta on
200
+ // top. The comment claimed that could not happen. It is now written under the same lock, one
201
+ // statement after the ledger, which is what the comment always described.
185
202
  session.booked = {
186
203
  reads: Math.max(readCount, booked.reads),
187
204
  writes: Math.max(totalWritesNow, booked.writes),
@@ -197,6 +214,16 @@ async function main() {
197
214
  real_api_calls: Math.max(realUsage?.api_calls ?? 0, booked.real_api_calls),
198
215
  };
199
216
  writeJSON(ledgerPath, ledger);
217
+ // Persist the session's own bookkeeping here, still holding the ledger lock. The deltas are
218
+ // re-applied to whatever is on disk right now, so a PostToolUse hook that wrote between this
219
+ // hook's initial read and this line does not lose its update.
220
+ const bookedNow = session.booked;
221
+ const remindersNow = session.reminders_shown;
222
+ updateSession(sessionFile, session, (s) => {
223
+ s.booked = bookedNow;
224
+ s.reminders_shown = remindersNow;
225
+ s.stop_count = Math.max(s.stop_count ?? 0, session.stop_count);
226
+ });
200
227
  });
201
228
  // Write a session summary line to memory.md if there was meaningful activity
202
229
  const memoryPath = path.join(wolfDir, "memory.md");
@@ -227,7 +254,6 @@ async function main() {
227
254
  compactMemoryIfLarge(wolfDir, ret.memory_max_bytes);
228
255
  }
229
256
  catch { }
230
- writeJSON(sessionFile, session);
231
257
  // Surface reminders into Claude's next context window (Stop hooks can inject via stdout JSON).
232
258
  if (reminders.length > 0) {
233
259
  const additionalContext = `⚠️ OpenWolf end-of-turn reminders:\n${reminders.map((r) => `• ${r}`).join("\n")}`;
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { findProjectRoot } from "../scanner/project-root.js";
4
- import { readJSON, writeJSON, withLock } from "../utils/fs-safe.js";
4
+ import { readJSON, writeJSON, withLockOr } from "../utils/fs-safe.js";
5
5
  import { readDashboardToken } from "../utils/dashboard-auth.js";
6
6
  import { Logger } from "../utils/logger.js";
7
7
  import { CronEngine } from "../daemon/cron-engine.js";
@@ -92,7 +92,7 @@ export function cronRetry(id) {
92
92
  // This CLI runs in its own process while the daemon's cron engine may be appending to the same
93
93
  // file — read-modify-write under the shared lock so neither side clobbers the other.
94
94
  const statePath = path.join(wolfDir, "cron-state.json");
95
- const removed = withLock(statePath, () => {
95
+ const removed = withLockOr(statePath, () => false, () => {
96
96
  const state = readJSON(statePath, {
97
97
  engine_status: "unknown",
98
98
  execution_log: [],
@@ -1 +1 @@
1
- {"version":3,"file":"cron-cmd.js","sourceRoot":"","sources":["../../../src/cli/cron-cmd.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cron-cmd.js","sourceRoot":"","sources":["../../../src/cli/cron-cmd.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAY,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAChF,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAqBtD,MAAM,UAAU,QAAQ;IACtB,MAAM,WAAW,GAAG,eAAe,EAAE,CAAC;IACtC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;IAEhD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;QAC5B,OAAO,CAAC,GAAG,CAAC,8CAA8C,CAAC,CAAC;QAC5D,OAAO;IACT,CAAC;IAED,MAAM,QAAQ,GAAG,QAAQ,CAAe,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,oBAAoB,CAAC,EAAE;QAChF,OAAO,EAAE,CAAC;QACV,KAAK,EAAE,EAAE;KACV,CAAC,CAAC;IAEH,MAAM,KAAK,GAAG,QAAQ,CAAY,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,iBAAiB,CAAC,EAAE;QACvE,aAAa,EAAE,SAAS;QACxB,aAAa,EAAE,EAAE;QACjB,iBAAiB,EAAE,EAAE;KACtB,CAAC,CAAC;IAEH,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;IAC1B,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAE5B,IAAI,QAAQ,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAChC,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,OAAO;IACT,CAAC;IAED,KAAK,MAAM,IAAI,IAAI,QAAQ,CAAC,KAAK,EAAE,CAAC;QAClC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC;QACrD,MAAM,OAAO,GAAG,KAAK,CAAC,aAAa;aAChC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,IAAI,CAAC,EAAE,CAAC;aACpC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7D,MAAM,UAAU,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,OAAO,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;QACnF,MAAM,MAAM,GAAG,KAAK,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,IAAI,CAAC,EAAE,CAAC,CAAC;QAE1E,OAAO,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC;QAC3C,OAAO,CAAC,GAAG,CAAC,iBAAiB,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;QAC9C,OAAO,CAAC,GAAG,CAAC,eAAe,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QACxE,OAAO,CAAC,GAAG,CAAC,iBAAiB,UAAU,EAAE,CAAC,CAAC;QAC3C,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;QACvC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,EAAU;IACtC,MAAM,WAAW,GAAG,eAAe,EAAE,CAAC;IACtC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;IAEhD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;QAC5B,OAAO,CAAC,GAAG,CAAC,8CAA8C,CAAC,CAAC;QAC5D,OAAO;IACT,CAAC;IAID,MAAM,MAAM,GAAG,QAAQ,CAAa,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,aAAa,CAAC,EAAE;QACrE,QAAQ,EAAE,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE;KACzC,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC;IAE5C,+CAA+C;IAC/C,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,oBAAoB,IAAI,iBAAiB,kBAAkB,CAAC,EAAE,CAAC,EAAE,EAAE;YACzF,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,kBAAkB,CAAC,OAAO,CAAC,EAAE;SACjG,CAAC,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAyC,CAAC;QACrE,IAAI,GAAG,CAAC,EAAE,EAAE,CAAC;YACX,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,wBAAwB,CAAC,CAAC;YAChD,OAAO;QACT,CAAC;QACD,OAAO,CAAC,GAAG,CAAC,0BAA0B,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC;QACtE,OAAO,CAAC,GAAG,CAAC,qCAAqC,CAAC,CAAC;IACrD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,GAAG,CAAC,gDAAgD,CAAC,CAAC;IAChE,CAAC;IAED,iDAAiD;IACjD,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,YAAY,CAAC,EAAE,MAAM,CAAC,CAAC;IACpE,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACtE,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QACzB,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,yBAAyB,CAAC,CAAC;IACnD,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,YAAY,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACxF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,EAAU;IAClC,MAAM,WAAW,GAAG,eAAe,EAAE,CAAC;IACtC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;IAEhD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;QAC5B,OAAO,CAAC,GAAG,CAAC,8CAA8C,CAAC,CAAC;QAC5D,OAAO;IACT,CAAC;IAED,+FAA+F;IAC/F,qFAAqF;IACrF,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,iBAAiB,CAAC,CAAC;IACxD,MAAM,OAAO,GAAG,UAAU,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE;QACtD,MAAM,KAAK,GAAG,QAAQ,CAAY,SAAS,EAAE;YAC3C,aAAa,EAAE,SAAS;YACxB,aAAa,EAAE,EAAE;YACjB,iBAAiB,EAAE,EAAE;SACtB,CAAC,CAAC;QACH,MAAM,GAAG,GAAG,KAAK,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,EAAE,CAAC,CAAC;QACvE,IAAI,GAAG,KAAK,CAAC,CAAC;YAAE,OAAO,KAAK,CAAC;QAC7B,KAAK,CAAC,iBAAiB,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;QACvC,SAAS,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QAC5B,OAAO,IAAI,CAAC;IACd,CAAC,CAAC,CAAC;IACH,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,kCAAkC,CAAC,CAAC;QAC1D,OAAO;IACT,CAAC;IACD,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE,0DAA0D,CAAC,CAAC;AACvF,CAAC"}
@@ -3,10 +3,11 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { findProjectRoot } from "../scanner/project-root.js";
6
- import { readJSON, writeJSON, withLock } from "../utils/fs-safe.js";
6
+ import { readJSON, writeJSON, tryWithLock } from "../utils/fs-safe.js";
7
7
  import { readDashboardToken } from "../utils/dashboard-auth.js";
8
8
  import { isPortFree } from "../utils/ports.js";
9
- import { isWindows } from "../utils/platform.js";
9
+ import { isWindows, execShim } from "../utils/platform.js";
10
+ import { readOwnDaemonPid, clearDaemonRecord } from "../utils/daemon-pid.js";
10
11
  const __filename = fileURLToPath(import.meta.url);
11
12
  const __dirname = path.dirname(__filename);
12
13
  function getDashboardPort() {
@@ -15,7 +16,9 @@ function getDashboardPort() {
15
16
  const config = readJSON(path.join(wolfDir, "config.json"), { openwolf: { dashboard: { port: 18791 } } });
16
17
  return config.openwolf.dashboard.port;
17
18
  }
18
- // On Windows the pm2 shim is pm2.cmd; execFileSync needs the exact name (no shell).
19
+ // On Windows the pm2 shim is pm2.cmd. Node cannot spawn a .cmd without a shell (CVE-2024-27980
20
+ // hardening), so every call goes through execShim(), which routes it via cmd.exe with the
21
+ // arguments still quoted individually rather than joined into a parseable string.
19
22
  const PM2 = isWindows() ? "pm2.cmd" : "pm2";
20
23
  function getPm2Name() {
21
24
  const projectRoot = findProjectRoot();
@@ -102,7 +105,7 @@ function killPid(pid) {
102
105
  function markDaemonStopped(wolfDir) {
103
106
  const statePath = path.join(wolfDir, "cron-state.json");
104
107
  try {
105
- withLock(statePath, () => {
108
+ tryWithLock(statePath, () => {
106
109
  const state = readJSON(statePath, {});
107
110
  // No cron state at all means the daemon never wrote one — nothing to correct, and inventing
108
111
  // a file here would only confuse the next start.
@@ -157,11 +160,11 @@ export function daemonStart() {
157
160
  // Resolve daemon script relative to openwolf's install dir, not the target project
158
161
  const daemonScript = path.resolve(__dirname, "..", "daemon", "wolf-daemon.js");
159
162
  try {
160
- execFileSync(PM2, ["start", daemonScript, "--name", name, "--cwd", projectRoot, "--", "--env", `OPENWOLF_PROJECT_ROOT=${projectRoot}`], {
163
+ execShim(PM2, ["start", daemonScript, "--name", name, "--cwd", projectRoot, "--", "--env", `OPENWOLF_PROJECT_ROOT=${projectRoot}`], {
161
164
  stdio: "inherit",
162
165
  env: { ...process.env, OPENWOLF_PROJECT_ROOT: projectRoot },
163
166
  });
164
- execFileSync(PM2, ["save"], { stdio: "ignore" });
167
+ execShim(PM2, ["save"], { stdio: "ignore" });
165
168
  console.log(`\n ✓ Daemon started: ${name}`);
166
169
  if (isWindows()) {
167
170
  console.log(" Tip: Run 'pm2-windows-startup' for boot persistence.");
@@ -185,7 +188,7 @@ export async function daemonStop() {
185
188
  if (hasPm2()) {
186
189
  const name = getPm2Name();
187
190
  try {
188
- execFileSync(PM2, ["stop", name], { stdio: "ignore" });
191
+ execShim(PM2, ["stop", name], { stdio: "ignore" });
189
192
  markDaemonStopped(wolfDir);
190
193
  console.log(` ✓ Daemon stopped (PM2): ${name}`);
191
194
  return;
@@ -201,19 +204,36 @@ export async function daemonStop() {
201
204
  console.log(` ✓ Daemon stopped cleanly (port ${port})`);
202
205
  return;
203
206
  }
204
- // Last resort: kill whatever is listening, then write the state the handler never got to write.
205
- const pid = findPidOnPort(port);
206
- if (pid) {
207
- if (killPid(pid)) {
208
- markDaemonStopped(wolfDir);
209
- console.log(` ✓ Daemon stopped (PID ${pid} on port ${port})`);
207
+ // Last resort: kill the daemon and ONLY the daemon.
208
+ //
209
+ // This used to kill whatever was listening on the configured port. That port is a number in a
210
+ // committed config file, the daemon may have been moved off it by `openwolf dashboard`, and on a
211
+ // developer machine it is just as likely to be a dev server or another project's daemon. Killing
212
+ // that and printing "✓ Daemon stopped" is the worst possible combination.
213
+ //
214
+ // The daemon now records its own pid, project root and host after a successful bind. A process is
215
+ // killed only when that record vouches for it. Anything else on the port is reported, not touched.
216
+ const ownPid = readOwnDaemonPid(wolfDir, projectRoot);
217
+ const listening = findPidOnPort(port);
218
+ if (ownPid === null) {
219
+ if (listening) {
220
+ console.log(` No daemon of this project is running. Port ${port} is held by PID ${listening}, which OpenWolf did not start — left alone.`);
210
221
  }
211
222
  else {
212
- console.error(` Failed to kill process ${pid} on port ${port}.`);
223
+ console.log(` No daemon running on port ${port}.`);
213
224
  }
225
+ return;
226
+ }
227
+ if (listening !== null && listening !== ownPid) {
228
+ console.log(` Stopping this project's daemon (PID ${ownPid}). Note: port ${port} is held by a different process (PID ${listening}), which is left alone.`);
229
+ }
230
+ if (killPid(ownPid)) {
231
+ markDaemonStopped(wolfDir);
232
+ clearDaemonRecord(wolfDir);
233
+ console.log(` ✓ Daemon stopped (PID ${ownPid})`);
214
234
  }
215
235
  else {
216
- console.log(` No daemon running on port ${port}.`);
236
+ console.error(` Failed to stop the daemon (PID ${ownPid}).`);
217
237
  }
218
238
  }
219
239
  export async function daemonRestart() {
@@ -227,7 +247,7 @@ export async function daemonRestart() {
227
247
  if (hasPm2()) {
228
248
  const name = getPm2Name();
229
249
  try {
230
- execFileSync(PM2, ["restart", name], { stdio: "ignore" });
250
+ execShim(PM2, ["restart", name], { stdio: "ignore" });
231
251
  console.log(` ✓ Daemon restarted (PM2): ${name}`);
232
252
  return;
233
253
  }
@@ -242,11 +262,19 @@ export async function daemonRestart() {
242
262
  console.log(" Stopped old daemon cleanly.");
243
263
  }
244
264
  else {
245
- const pid = findPidOnPort(port);
246
- if (pid) {
247
- killPid(pid);
265
+ // Same ownership rule as daemonStop: a restart that kills a stranger on the port is a restart
266
+ // that took down somebody else's server and then told you it restarted yours.
267
+ const ownPid = readOwnDaemonPid(wolfDir, projectRoot);
268
+ if (ownPid !== null) {
269
+ killPid(ownPid);
248
270
  markDaemonStopped(wolfDir);
249
- console.log(` Stopped old daemon (PID ${pid}).`);
271
+ clearDaemonRecord(wolfDir);
272
+ console.log(` Stopped old daemon (PID ${ownPid}).`);
273
+ }
274
+ else {
275
+ const listening = findPidOnPort(port);
276
+ if (listening)
277
+ console.log(` Port ${port} is held by PID ${listening}, which OpenWolf did not start — left alone.`);
250
278
  }
251
279
  }
252
280
  console.log(" Use 'openwolf dashboard' to start a new daemon.");
@@ -264,7 +292,7 @@ export function daemonLogs() {
264
292
  }
265
293
  const name = getPm2Name();
266
294
  try {
267
- execFileSync(PM2, ["logs", name, "--lines", "50", "--nostream"], { stdio: "inherit" });
295
+ execShim(PM2, ["logs", name, "--lines", "50", "--nostream"], { stdio: "inherit" });
268
296
  }
269
297
  catch {
270
298
  console.error("Failed to get daemon logs.");