@theronap/agnoclast-mcp 0.9.180 → 0.9.182
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/lib/arrivals_notice.mjs +29 -23
- package/lib/server.mjs +25 -0
- package/lib/working_files.mjs +249 -0
- package/package.json +1 -1
package/lib/arrivals_notice.mjs
CHANGED
|
@@ -56,6 +56,10 @@ import { fetchCortex, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
|
56
56
|
* to — but this is a hook that prints unprompted, and a notice that dumps content trains its reader
|
|
57
57
|
* to skip it. Exported for the tests.
|
|
58
58
|
*/
|
|
59
|
+
// Enough to hold a session's own recent output in full; small enough that the notice is READ rather
|
|
60
|
+
// than spilled to a file, which is what happened at 49 KB.
|
|
61
|
+
const MAX_YOURS = 25
|
|
62
|
+
|
|
59
63
|
export function formatNotice(signals) {
|
|
60
64
|
const lines = []
|
|
61
65
|
const offers = signals?.settling?.offers ?? []
|
|
@@ -66,32 +70,34 @@ export function formatNotice(signals) {
|
|
|
66
70
|
lines.push(`⚡ ${delta} new arrival${delta === 1 ? '' : 's'} in the pile since this session was last told.`)
|
|
67
71
|
}
|
|
68
72
|
if (offers.length) {
|
|
69
|
-
|
|
70
|
-
// ⚠ EVERY OFFER, GROUPED BY CASCADE STAGE — changed 2026-09-25 from the first SIX.
|
|
73
|
+
// ⭐ LEAD WITH WHAT IS PROVABLY THIS SESSION'S, AND ONLY LIST THAT (2026-09-25).
|
|
71
74
|
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// The title is what makes a row judgeable; an id names a unit without saying what it is.
|
|
87
|
-
for (const o of group) {
|
|
88
|
-
const name = o.title ? ` ${o.title}` : ''
|
|
89
|
-
lines.push(` ${o.intakeItemId}${name} · ${o.source} · ${o.hoursAgo}h`)
|
|
75
|
+
// Measured on the session that built this, as the one receiving it: 6 bare ids — filed nothing; 205
|
|
76
|
+
// titled rows — filed nothing; 350 rows at 49 KB — the harness spilled it to a file and showed a 2 KB
|
|
77
|
+
// preview, so it was not even read. Filed immediately: the one record it had looked at and could see
|
|
78
|
+
// was its own. What moves an agent is certainty, a short list, and the reason stated. So the rows
|
|
79
|
+
// shown are the ones whose files THIS SESSION CHANGED (`worked`), each with the file that proves it,
|
|
80
|
+
// framed as the step before the answer; every other tier is one line of counts.
|
|
81
|
+
const yours = offers.filter((o) => o.via_reason === 'matched' && (o.worked ?? []).length)
|
|
82
|
+
const rest = offers.filter((o) => !yours.includes(o))
|
|
83
|
+
if (yours.length) {
|
|
84
|
+
const n = yours.length
|
|
85
|
+
lines.push(`⚡ ${n} arrival${n === 1 ? '' : 's'} came from work THIS SESSION did — you changed the files ${n === 1 ? 'it touches' : 'they touch'}. File ${n === 1 ? 'it' : 'them'} before anything else:`)
|
|
86
|
+
for (const o of yours.slice(0, MAX_YOURS)) {
|
|
87
|
+
const files = o.worked.slice(0, 2).map((f) => f.split('/').pop()).join(', ')
|
|
88
|
+
lines.push(` ${o.intakeItemId} ${o.title ?? '(unreadable)'} · ${o.source} · ${o.hoursAgo}h — you changed ${files}`)
|
|
90
89
|
}
|
|
90
|
+
if (n > MAX_YOURS) lines.push(` … and ${n - MAX_YOURS} more that are yours`)
|
|
91
|
+
lines.push(' `intake_look <id>` shows where one would go · `intake_materialize` files it · `not_mine` if one is not yours.')
|
|
91
92
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
const counts = { matched: 0, orphan: 0, declined: 0 }
|
|
94
|
+
for (const o of rest) counts[o.via_reason] = (counts[o.via_reason] ?? 0) + 1
|
|
95
|
+
const parts = []
|
|
96
|
+
if (counts.matched) parts.push(`${counts.matched} loosely matching your work`)
|
|
97
|
+
if (counts.orphan) parts.push(`${counts.orphan} matching no session`)
|
|
98
|
+
if (counts.declined) parts.push(`${counts.declined} declined by other sessions`)
|
|
99
|
+
if (total > offers.length) parts.push(`${total - offers.length} beyond the scan window`)
|
|
100
|
+
if (parts.length) lines.push(`⚡ Also settling: ${parts.join(' · ')} — not listed here; \`intake_changes\` shows them.`)
|
|
95
101
|
}
|
|
96
102
|
return lines.join('\n')
|
|
97
103
|
}
|
package/lib/server.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { renderCaptureStatus } from './capture_status.mjs'
|
|
|
16
16
|
import { renderTriage } from './red_link_triage.mjs'
|
|
17
17
|
import { runCodeGraphQuery } from './code_graph_cli.mjs'
|
|
18
18
|
import { repoFullNameFrom, workingFilesFrom } from './capture.mjs'
|
|
19
|
+
import { findTranscript, createWorkedFilesTracker } from './working_files.mjs'
|
|
19
20
|
import { spawnObligationWorker } from './obligations_worker.mjs'
|
|
20
21
|
import { renderObligations } from './obligations_render.mjs'
|
|
21
22
|
|
|
@@ -185,6 +186,25 @@ export async function runServer(version) {
|
|
|
185
186
|
// server labels actor_source from this and watches for `mcp` + no session. Tying the two together
|
|
186
187
|
// would make that pair impossible and blind the detector. See actorSourceFrom in auth_cloud.ts.
|
|
187
188
|
setClientVersion(version)
|
|
189
|
+
// ADR-0066 §7 — the files this session WORKED ON, read from its own transcript.
|
|
190
|
+
//
|
|
191
|
+
// Keyed on CLAUDE_CODE_SESSION_ID, never SESSION_KEY: SESSION_KEY falls back to a random uuid on
|
|
192
|
+
// hosts that do not set the env var, and no transcript is named after a random uuid. Created LAZILY
|
|
193
|
+
// because a brand-new session's transcript may not exist yet when this server starts; each
|
|
194
|
+
// heartbeat retries until it appears, then the tracker reads only what was appended since.
|
|
195
|
+
let workedFiles = null
|
|
196
|
+
function currentWorkedFiles() {
|
|
197
|
+
try {
|
|
198
|
+
if (!workedFiles) {
|
|
199
|
+
const t = findTranscript(process.env.CLAUDE_CODE_SESSION_ID)
|
|
200
|
+
if (!t) return []
|
|
201
|
+
workedFiles = createWorkedFilesTracker(t)
|
|
202
|
+
}
|
|
203
|
+
return workedFiles.poll(40)
|
|
204
|
+
} catch {
|
|
205
|
+
return [] // attention data is never worth disturbing a session for
|
|
206
|
+
}
|
|
207
|
+
}
|
|
188
208
|
async function pingSession() {
|
|
189
209
|
try {
|
|
190
210
|
await fetchCortex(`${BASE}/api/session-ping`, {
|
|
@@ -208,6 +228,11 @@ export async function runServer(version) {
|
|
|
208
228
|
// of 217 arrivals "matched" on a live session — a pile with no bounded action in it.
|
|
209
229
|
// Best-effort and bounded in workingFilesFrom; a non-repo cwd yields [] and is omitted.
|
|
210
230
|
files: workingFilesFrom(process.cwd()).slice(0, 40),
|
|
231
|
+
// workedFiles (2026-09-25): the files the session actually touched, from its transcript,
|
|
232
|
+
// already qualified as file:<owner/name>:<path>. `files` above reads git in the LAUNCH
|
|
233
|
+
// directory, and every desktop-launched session launches from home — measured, all five
|
|
234
|
+
// of Theron's live sessions — so it registered nothing. This is the one that works.
|
|
235
|
+
workedFiles: currentWorkedFiles(),
|
|
211
236
|
}),
|
|
212
237
|
})
|
|
213
238
|
} catch { /* best-effort heartbeat — never disrupt the session */ }
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// ADR-0066 §7 — the FILES a session is working on, read from WHERE IT WORKED, not where it launched.
|
|
2
|
+
//
|
|
3
|
+
// 🔴 THIS IS THE SECOND TIME THIS EXACT MISTAKE HAS SHIPPED. 0.9.180 read git from the MCP server's
|
|
4
|
+
// process.cwd(). Measured the same day: all five of Theron's live sessions report cwd =
|
|
5
|
+
// /Users/theronpeterson — the desktop app launches every session from home, which is not a repo — so
|
|
6
|
+
// the feature registered NOTHING for any of them. capture.mjs documents the identical failure from
|
|
7
|
+
// 2026-08-03 ("still zero carried an identifier … because Claude Code sessions launch from wherever
|
|
8
|
+
// the terminal happened to be") and already fixed it the right way: read the transcript, because the
|
|
9
|
+
// transcript records the files the session actually read and wrote. This reuses that primitive.
|
|
10
|
+
//
|
|
11
|
+
// ⚠ INCREMENTAL, BECAUSE THE TRANSCRIPT IS LARGE AND THIS RUNS EVERY MINUTE. A long session's
|
|
12
|
+
// transcript measured 37.7 MB. The tracker remembers its byte offset and reads only what was
|
|
13
|
+
// appended since the last heartbeat; the first read starts from the tail, since recent work is what
|
|
14
|
+
// "working on" means.
|
|
15
|
+
//
|
|
16
|
+
// ⚠ CHANGED FILES ONLY — reading one is not working on it (2026-09-25, same day as the above). The
|
|
17
|
+
// first cut recorded every file the session READ as well, and a session investigating a bug reads
|
|
18
|
+
// dozens. Those holds then surface OTHER sessions' work as "yours, you worked on this file", which is
|
|
19
|
+
// certainty the signal does not have — and a nudge that claims certainty it lacks is how an agent
|
|
20
|
+
// learns to stop reading them. The existing rule for pages already draws this line: a deliberate
|
|
21
|
+
// open counts, a grep hit does not. For files, the floor is a change.
|
|
22
|
+
//
|
|
23
|
+
// ⚠ ATTENTION DATA, NOT A CLEARANCE STAMP. capture.mjs uses the same paths to stamp REPOS onto records
|
|
24
|
+
// behind a MIN_FILES_FOR_STAMP security threshold, because a stamp grants other people read access.
|
|
25
|
+
// Nothing here grants anything: these identifiers only make this person's OWN sessions candidates for
|
|
26
|
+
// this person's OWN arrivals. So it takes every file touched rather than thresholding — and it
|
|
27
|
+
// deliberately does not modify touchedPaths, whose extraction is part of that security surface.
|
|
28
|
+
import { readdirSync, existsSync, statSync, openSync, readSync, closeSync } from 'node:fs'
|
|
29
|
+
import { homedir } from 'node:os'
|
|
30
|
+
import { join, dirname } from 'node:path'
|
|
31
|
+
import { execFileSync } from 'node:child_process'
|
|
32
|
+
import { touchedPaths, repoFullNameFrom } from './capture.mjs'
|
|
33
|
+
|
|
34
|
+
const FIRST_READ_TAIL = 2 * 1024 * 1024 // on first poll, only the recent end of a long transcript
|
|
35
|
+
const MAX_READ_PER_POLL = 4 * 1024 * 1024
|
|
36
|
+
const MAX_DIR_PROBES_PER_POLL = 25 // each probe is a git subprocess; bound the heartbeat's cost
|
|
37
|
+
const MAX_KEPT = 200
|
|
38
|
+
|
|
39
|
+
// touchedPaths only recognises Bash paths under /Users, /home, /opt, /srv, /var — right for its
|
|
40
|
+
// security job, but git worktrees commonly live under /private/tmp or /tmp, and work there would be
|
|
41
|
+
// invisible. Widened HERE, for attention only, rather than in the shared extractor.
|
|
42
|
+
const EXTRA_BASH_PATHS = /\/(?:private|tmp)\/[^\s"'`;|&)<>]+/g
|
|
43
|
+
|
|
44
|
+
/** Paths one transcript line touched. Exported for the tests. */
|
|
45
|
+
export function pathsInLine(line) {
|
|
46
|
+
const out = touchedPaths(line)
|
|
47
|
+
let d
|
|
48
|
+
try { d = JSON.parse(line) } catch { return out }
|
|
49
|
+
for (const b of d?.message?.content ?? []) {
|
|
50
|
+
if (b?.type === 'tool_use' && typeof b.input?.command === 'string') {
|
|
51
|
+
for (const m of b.input.command.match(EXTRA_BASH_PATHS) ?? []) out.push(m)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
|
|
58
|
+
const ABS_PATH = /\/(?:Users|home|opt|srv|var|private|tmp)\/[^\s"'`;|&)<>]+/g
|
|
59
|
+
// A command that writes through a script: every absolute path it names is a candidate target.
|
|
60
|
+
// Over-inclusive for a script that reads one file and writes another, which is the safe side here —
|
|
61
|
+
// the alternative is missing the edit, and the edit is the whole signal.
|
|
62
|
+
const SCRIPTED_WRITE = /open\([^)]*['"][wa]b?\+?['"]|writeFileSync|writeFile\(|write_text\(|Bun\.write\(|sed\s+(?:-\S+\s+)*-i\b|\bperl\s+-\S*i/
|
|
63
|
+
const REDIRECT_TARGET = /(?:^|[^<0-9&>])>>?\s*(['"]?)(\/[^\s'"`;|&)<>]+)\1/g
|
|
64
|
+
const TEE_TARGET = /\btee\s+(?:-a\s+)?(['"]?)(\/[^\s'"`;|&)<>]+)\1/g
|
|
65
|
+
const COPY_MOVE = /\b(?:cp|mv|install)\b[^;|&\n]*/g
|
|
66
|
+
|
|
67
|
+
// `W=/private/tmp/wt; cat > "$W/web/app.ts"` names its target only through the variable, and that is
|
|
68
|
+
// how a great deal of real editing is written. Resolve simple absolute assignments in the same command.
|
|
69
|
+
function expandVarsFrom(shell, text) {
|
|
70
|
+
const vars = {}
|
|
71
|
+
for (const m of shell.matchAll(/(?:^|[\s;&(])([A-Za-z_][A-Za-z0-9_]*)=(['"]?)(\/[^\s'"`;|&)<>]*)\2/g)) vars[m[1]] = m[3]
|
|
72
|
+
return text.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (all, name) => (name in vars ? vars[name] : all))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Split a Bash command into the shell text and its heredoc bodies, remembering which command line fed
|
|
76
|
+
// each body. The distinction is the difference between content and code: the body of
|
|
77
|
+
// `cat > f <<EOF` is the file being written, and any path-looking text in it (a code comment giving an
|
|
78
|
+
// example command, say) is not a write; the body of `python3 - <<PY` IS the script doing the writing.
|
|
79
|
+
// Measured: scanning bodies indiscriminately reported `web/app.ts` — an example inside a comment.
|
|
80
|
+
const INTERPRETER = /\b(?:python3?|node|bun|bash|sh|zsh|ruby|perl)\b/
|
|
81
|
+
const SHELL_INTERPRETER = /\b(?:bash|sh|zsh)\b/
|
|
82
|
+
function splitHeredocs(cmd) {
|
|
83
|
+
const lines = cmd.split('\n')
|
|
84
|
+
const shell = []
|
|
85
|
+
const bodies = []
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
const line = lines[i]
|
|
88
|
+
shell.push(line)
|
|
89
|
+
const m = line.match(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/)
|
|
90
|
+
if (!m) continue
|
|
91
|
+
const end = lines.findIndex((l, j) => j > i && l.trim() === m[2])
|
|
92
|
+
const stop = end < 0 ? lines.length : end
|
|
93
|
+
bodies.push({ owner: line, body: lines.slice(i + 1, stop).join('\n'), quoted: Boolean(m[1]) })
|
|
94
|
+
if (end >= 0) shell.push(lines[end])
|
|
95
|
+
i = stop
|
|
96
|
+
}
|
|
97
|
+
return { shell: shell.join('\n'), bodies }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writesIn(text, out) {
|
|
101
|
+
if (SCRIPTED_WRITE.test(text)) out.push(...(text.match(ABS_PATH) ?? []))
|
|
102
|
+
for (const m of text.matchAll(REDIRECT_TARGET)) out.push(m[2])
|
|
103
|
+
for (const m of text.matchAll(TEE_TARGET)) out.push(m[2])
|
|
104
|
+
for (const seg of text.match(COPY_MOVE) ?? []) {
|
|
105
|
+
const paths = seg.match(ABS_PATH) ?? []
|
|
106
|
+
if (paths.length >= 2) out.push(paths[paths.length - 1])
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Paths one transcript line CHANGED. Exported for the tests.
|
|
112
|
+
*
|
|
113
|
+
* The edit tools name their target exactly. Bash is heuristic by necessity: a redirect or `tee`
|
|
114
|
+
* target, every path in a `sed -i` or a scripted write, and the destination of `cp`/`mv`/`install`.
|
|
115
|
+
* A plain `cat`, `grep` or `sed -n` of a path is a read and is not counted.
|
|
116
|
+
*/
|
|
117
|
+
export function editedPathsInLine(line) {
|
|
118
|
+
const out = []
|
|
119
|
+
let d
|
|
120
|
+
try { d = JSON.parse(line) } catch { return out }
|
|
121
|
+
for (const b of d?.message?.content ?? []) {
|
|
122
|
+
if (b?.type !== 'tool_use') continue
|
|
123
|
+
const inp = b.input ?? {}
|
|
124
|
+
if (EDIT_TOOLS.has(b.name)) {
|
|
125
|
+
for (const k of ['file_path', 'notebook_path']) {
|
|
126
|
+
if (typeof inp[k] === 'string' && inp[k].startsWith('/')) out.push(inp[k])
|
|
127
|
+
}
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (typeof inp.command !== 'string') continue
|
|
131
|
+
const { shell, bodies } = splitHeredocs(inp.command)
|
|
132
|
+
// Variables are resolved from the SHELL text only: an assignment inside a file's content is not
|
|
133
|
+
// an assignment the shell ever made.
|
|
134
|
+
const expand = (t) => expandVarsFrom(shell, t)
|
|
135
|
+
writesIn(expand(shell), out)
|
|
136
|
+
for (const { owner, body, quoted } of bodies) {
|
|
137
|
+
// Shell semantics, exactly: a QUOTED delimiter (<<'PY') hands the body over literally, so a `$W`
|
|
138
|
+
// inside it is text, not a path. Expanding it anyway turned an example in a code comment into a
|
|
139
|
+
// "changed" file that does not exist.
|
|
140
|
+
const text = quoted ? body : expand(body)
|
|
141
|
+
if (SHELL_INTERPRETER.test(owner)) writesIn(text, out) // a shell script: full rules
|
|
142
|
+
else if (INTERPRETER.test(owner)) { // python/node/…: its writes
|
|
143
|
+
if (SCRIPTED_WRITE.test(text)) out.push(...(text.match(ABS_PATH) ?? [])) // shell syntax is data
|
|
144
|
+
}
|
|
145
|
+
// else: content being written by cat/tee — its target is already on the owner line
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return [...new Set(out)]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Find this session's transcript by its id alone. Scanning for `<id>.jsonl` avoids depending on how
|
|
153
|
+
* Claude Code slugs the launch directory into a folder name — the id is unique, the slug rule is not
|
|
154
|
+
* ours to rely on.
|
|
155
|
+
*/
|
|
156
|
+
export function findTranscript(sessionId, projectsDir = join(homedir(), '.claude', 'projects')) {
|
|
157
|
+
if (!sessionId) return null
|
|
158
|
+
try {
|
|
159
|
+
for (const d of readdirSync(projectsDir)) {
|
|
160
|
+
const p = join(projectsDir, d, `${sessionId}.jsonl`)
|
|
161
|
+
if (existsSync(p)) return p
|
|
162
|
+
}
|
|
163
|
+
} catch { /* no projects dir: a non-Claude-Code host */ }
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function gitRoot(dir) {
|
|
168
|
+
try {
|
|
169
|
+
return execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
|
|
170
|
+
encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
171
|
+
}).trim() || null
|
|
172
|
+
} catch {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Tracks the files a session touches, reading its transcript incrementally.
|
|
179
|
+
* `poll()` returns fully-qualified identifiers, most recent first: `file:<owner/name>:<repo-relative>`.
|
|
180
|
+
*/
|
|
181
|
+
export function createWorkedFilesTracker(transcriptPath, opts = {}) {
|
|
182
|
+
const resolveRoot = opts.gitRoot ?? gitRoot
|
|
183
|
+
const resolveRepo = opts.repoFullNameFrom ?? repoFullNameFrom
|
|
184
|
+
let offset = -1
|
|
185
|
+
let carry = ''
|
|
186
|
+
const repoOfDir = new Map() // dir -> { root, repo } | null
|
|
187
|
+
const recent = new Map() // identifier -> true, insertion order = recency
|
|
188
|
+
|
|
189
|
+
function locate(p) {
|
|
190
|
+
const dir = dirname(p)
|
|
191
|
+
if (repoOfDir.has(dir)) return repoOfDir.get(dir)
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function poll(limit = 40) {
|
|
196
|
+
if (!transcriptPath) return []
|
|
197
|
+
let size
|
|
198
|
+
try { size = statSync(transcriptPath).size } catch { return [...recent.keys()].reverse().slice(0, limit) }
|
|
199
|
+
if (offset < 0 || size < offset) { // first read, or the file was rewritten
|
|
200
|
+
offset = Math.max(0, size - FIRST_READ_TAIL)
|
|
201
|
+
carry = ''
|
|
202
|
+
}
|
|
203
|
+
if (size > offset) {
|
|
204
|
+
const len = Math.min(size - offset, MAX_READ_PER_POLL)
|
|
205
|
+
const buf = Buffer.alloc(len)
|
|
206
|
+
let fd
|
|
207
|
+
try {
|
|
208
|
+
fd = openSync(transcriptPath, 'r')
|
|
209
|
+
readSync(fd, buf, 0, len, offset)
|
|
210
|
+
} catch { return [...recent.keys()].reverse().slice(0, limit) } finally { if (fd !== undefined) closeSync(fd) }
|
|
211
|
+
offset += len
|
|
212
|
+
const text = carry + buf.toString('utf8')
|
|
213
|
+
const nl = text.lastIndexOf('\n')
|
|
214
|
+
carry = nl >= 0 ? text.slice(nl + 1) : text
|
|
215
|
+
const lines = nl >= 0 ? text.slice(0, nl).split('\n') : []
|
|
216
|
+
|
|
217
|
+
const touched = []
|
|
218
|
+
for (const line of lines) if (line) touched.push(...editedPathsInLine(line))
|
|
219
|
+
|
|
220
|
+
let probes = 0
|
|
221
|
+
for (const p of touched) {
|
|
222
|
+
if (p.includes('/.git/') || p.endsWith('/')) continue
|
|
223
|
+
// A directory is never an edit, and one without a trailing slash would pass the server's shape
|
|
224
|
+
// check and re-introduce exactly the breadth file-level holds exist to remove. Measured: `cd` into
|
|
225
|
+
// `web` and `packages/cortex-mcp` in a writing command reported both as changed "files".
|
|
226
|
+
try { if (statSync(p).isDirectory()) continue } catch { /* gone or never existed: judged below */ }
|
|
227
|
+
const dir = dirname(p)
|
|
228
|
+
if (!repoOfDir.has(dir)) {
|
|
229
|
+
if (probes >= MAX_DIR_PROBES_PER_POLL) continue
|
|
230
|
+
probes++
|
|
231
|
+
const root = existsSync(dir) ? resolveRoot(dir) : null
|
|
232
|
+
const repo = root ? resolveRepo(root) : null
|
|
233
|
+
repoOfDir.set(dir, root && repo ? { root, repo } : null)
|
|
234
|
+
}
|
|
235
|
+
const hit = locate(p)
|
|
236
|
+
if (!hit || !p.startsWith(hit.root + '/')) continue
|
|
237
|
+
const rel = p.slice(hit.root.length + 1)
|
|
238
|
+
if (!rel) continue
|
|
239
|
+
const id = `file:${hit.repo}:${rel}`
|
|
240
|
+
recent.delete(id)
|
|
241
|
+
recent.set(id, true)
|
|
242
|
+
if (recent.size > MAX_KEPT) recent.delete(recent.keys().next().value)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return [...recent.keys()].reverse().slice(0, limit)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { poll }
|
|
249
|
+
}
|
package/package.json
CHANGED