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.
- package/CHANGELOG.md +306 -0
- package/dist/dashboard/assets/{AISuggestions-DZmzksSA.js → AISuggestions-C7WFvzLO.js} +1 -1
- package/dist/dashboard/assets/{ActivityLog-BJ0omuzu.js → ActivityLog-SsRbpBFt.js} +1 -1
- package/dist/dashboard/assets/{ActivityTimeline-BWhxretI.js → ActivityTimeline-BoV30nDY.js} +1 -1
- package/dist/dashboard/assets/{AnatomyBrowser-DNVp6-_r.js → AnatomyBrowser-DwzsHn4z.js} +1 -1
- package/dist/dashboard/assets/{BugLog-DMJTuU9p.js → BugLog-CPl4Q-JG.js} +1 -1
- package/dist/dashboard/assets/{CerebrumViewer-BwcGr6pu.js → CerebrumViewer-_f6fBsNF.js} +1 -1
- package/dist/dashboard/assets/{CronStatus-Dwf_M4-S.js → CronStatus-1iBEPJK5.js} +1 -1
- package/dist/dashboard/assets/{DesignQC-DzHkh94I.js → DesignQC-D4UU7lca.js} +1 -1
- package/dist/dashboard/assets/{LinkGraph-kqQD--Yj.js → LinkGraph-BWtI8Gh3.js} +1 -1
- package/dist/dashboard/assets/{MemoryViewer-BCn9Fr-w.js → MemoryViewer-BYRVG34g.js} +1 -1
- package/dist/dashboard/assets/{NativeMemory-DPMZaP5E.js → NativeMemory-Dphtrqjt.js} +1 -1
- package/dist/dashboard/assets/{ProjectOverview-PWENJiC4.js → ProjectOverview-CTYohvjD.js} +1 -1
- package/dist/dashboard/assets/{ProjectsAggregate-Baj44DVk.js → ProjectsAggregate-CMxNs35H.js} +1 -1
- package/dist/dashboard/assets/{StatusBadge-Db61D51H.js → StatusBadge-CjrFCDOI.js} +1 -1
- package/dist/dashboard/assets/{TokenUsage-UefDh9w4.js → TokenUsage-C5wJpMl8.js} +1 -1
- package/dist/dashboard/assets/index-BNi8lsJE.css +2 -0
- package/dist/dashboard/assets/{index-e2PfOI0b.js → index-C74qneS9.js} +5 -5
- package/dist/dashboard/index.html +2 -2
- package/dist/hooks/post-bash.js +10 -5
- package/dist/hooks/post-read.js +24 -26
- package/dist/hooks/post-write.js +135 -118
- package/dist/hooks/pre-read.js +42 -36
- package/dist/hooks/pre-write.js +3 -3
- package/dist/hooks/precompact.js +2 -2
- package/dist/hooks/session-start.js +15 -6
- package/dist/hooks/shared.js +221 -14
- package/dist/hooks/stop.js +33 -7
- package/dist/src/cli/cron-cmd.js +2 -2
- package/dist/src/cli/cron-cmd.js.map +1 -1
- package/dist/src/cli/daemon-cmd.js +49 -21
- package/dist/src/cli/daemon-cmd.js.map +1 -1
- package/dist/src/cli/doctor-cmd.js +2 -1
- package/dist/src/cli/doctor-cmd.js.map +1 -1
- package/dist/src/cli/export-cmd.js +8 -0
- package/dist/src/cli/export-cmd.js.map +1 -1
- package/dist/src/cli/init.js +34 -8
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/registry.js +68 -33
- package/dist/src/cli/registry.js.map +1 -1
- package/dist/src/cli/update.js +37 -6
- package/dist/src/cli/update.js.map +1 -1
- package/dist/src/daemon/cron-engine.js +70 -8
- package/dist/src/daemon/cron-engine.js.map +1 -1
- package/dist/src/daemon/file-watcher.js +4 -1
- package/dist/src/daemon/file-watcher.js.map +1 -1
- package/dist/src/daemon/llm-provider.js +9 -6
- package/dist/src/daemon/llm-provider.js.map +1 -1
- package/dist/src/daemon/wolf-daemon.js +69 -15
- package/dist/src/daemon/wolf-daemon.js.map +1 -1
- package/dist/src/designqc/designqc-capture.js +116 -6
- package/dist/src/designqc/designqc-capture.js.map +1 -1
- package/dist/src/designqc/designqc-engine.js +9 -3
- package/dist/src/designqc/designqc-engine.js.map +1 -1
- package/dist/src/hooks/post-bash.js +10 -5
- package/dist/src/hooks/post-bash.js.map +1 -1
- package/dist/src/hooks/post-read.js +24 -26
- package/dist/src/hooks/post-read.js.map +1 -1
- package/dist/src/hooks/post-write.js +135 -118
- package/dist/src/hooks/post-write.js.map +1 -1
- package/dist/src/hooks/pre-read.js +42 -36
- package/dist/src/hooks/pre-read.js.map +1 -1
- package/dist/src/hooks/pre-write.js +3 -3
- package/dist/src/hooks/pre-write.js.map +1 -1
- package/dist/src/hooks/precompact.js +2 -2
- package/dist/src/hooks/precompact.js.map +1 -1
- package/dist/src/hooks/session-start.js +15 -6
- package/dist/src/hooks/session-start.js.map +1 -1
- package/dist/src/hooks/shared.js +221 -14
- package/dist/src/hooks/shared.js.map +1 -1
- package/dist/src/hooks/stop.js +33 -7
- package/dist/src/hooks/stop.js.map +1 -1
- package/dist/src/scanner/anatomy-scanner.js +7 -3
- package/dist/src/scanner/anatomy-scanner.js.map +1 -1
- package/dist/src/utils/agent-hooks.js +50 -4
- package/dist/src/utils/agent-hooks.js.map +1 -1
- package/dist/src/utils/daemon-pid.js +56 -0
- package/dist/src/utils/daemon-pid.js.map +1 -0
- package/dist/src/utils/dashboard-auth.js +13 -1
- package/dist/src/utils/dashboard-auth.js.map +1 -1
- package/dist/src/utils/embeddings.js +9 -1
- package/dist/src/utils/embeddings.js.map +1 -1
- package/dist/src/utils/fs-safe.js +69 -11
- package/dist/src/utils/fs-safe.js.map +1 -1
- package/dist/src/utils/logger.js +21 -6
- package/dist/src/utils/logger.js.map +1 -1
- package/dist/src/utils/maintenance.js +100 -6
- package/dist/src/utils/maintenance.js.map +1 -1
- package/dist/src/utils/platform.js +39 -0
- package/dist/src/utils/platform.js.map +1 -1
- package/dist/src/utils/remote.js +2 -2
- package/dist/src/utils/remote.js.map +1 -1
- package/package.json +1 -1
- package/src/templates/config.json +34 -9
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/hooks/shared.js
CHANGED
|
@@ -74,10 +74,31 @@ export function ensureWolfDir() {
|
|
|
74
74
|
process.exit(0);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/hooks/stop.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
184
|
-
//
|
|
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")}`;
|
package/dist/src/cli/cron-cmd.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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":"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,
|
|
1
|
+
{"version":3,"file":"cron-cmd.js","sourceRoot":"","sources":["../../../src/cli/cron-cmd.ts"],"names":[],"mappings":"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"}
|
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
+
execShim(PM2, ["logs", name, "--lines", "50", "--nostream"], { stdio: "inherit" });
|
|
268
296
|
}
|
|
269
297
|
catch {
|
|
270
298
|
console.error("Failed to get daemon logs.");
|