@echomem/mcp 1.4.3 → 1.4.4
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/README.md +12 -7
- package/dist/hud/capsule.js +68 -25
- package/dist/hud/metric.js +16 -3
- package/dist/hud/monitor.js +152 -14
- package/dist/hud/web.js +594 -430
- package/dist/index.js +14 -1
- package/dist/package-metadata.js +4 -2
- package/dist/setup.js +28 -33
- package/dist/update-check.js +154 -0
- package/dist/v1-contract.js +18 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -49,18 +49,22 @@ your editor and you're done.
|
|
|
49
49
|
|---|---|
|
|
50
50
|
| `npm i -g @echomem/mcp@latest && echomem-mcp setup` | Install the CLI globally and run setup in one explicit step |
|
|
51
51
|
| `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
|
|
52
|
-
| `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop]` | Write client config + log in |
|
|
53
|
-
| `echomem-mcp setup --skip-login [--client cursor\|windsurf\|claude-desktop\|codex]` | Write client config without opening the browser or changing credentials |
|
|
54
|
-
| `npx -y @echomem/mcp@latest update
|
|
52
|
+
| `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
|
|
53
|
+
| `echomem-mcp setup --skip-login [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config without opening the browser or changing credentials |
|
|
54
|
+
| `npx -y @echomem/mcp@latest update --all` | One-shot update: repoint detected client configs to the latest bridge, with no browser login |
|
|
55
|
+
| `npx -y @echomem/mcp@latest update --client codex` | Update one client only |
|
|
55
56
|
| `echomem-mcp setup --with-hud [--client codex]` | Write client config + log in + launch the EchoMem context HUD |
|
|
56
57
|
| `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
|
|
57
58
|
| `echomem-mcp unlock` | Re-derive the encryption key after its TTL (or `--passphrase`) |
|
|
58
|
-
| `echomem-mcp status` | Show token / key / detected clients |
|
|
59
|
+
| `echomem-mcp status` | Show token / key / detected clients, configured bridge versions, and update guidance |
|
|
60
|
+
| `echomem-mcp doctor [--no-network]` | Diagnose configured client bridge versions |
|
|
59
61
|
| `echomem-mcp logout` | Remove stored credentials |
|
|
60
62
|
|
|
61
|
-
The bridge reports its package version in MCP server instructions and in
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
The bridge reports its package version in MCP server instructions and in tool descriptions. It also
|
|
64
|
+
checks npm for a newer published bridge using a cached, non-blocking check. Agents can call
|
|
65
|
+
`echomem_update_status` to show the user whether an update exists and then run
|
|
66
|
+
`npx -y @echomem/mcp@latest update --all` if the user agrees. The bridge does not auto-update on
|
|
67
|
+
every MCP startup.
|
|
64
68
|
|
|
65
69
|
## EchoMem Context HUD
|
|
66
70
|
|
|
@@ -185,6 +189,7 @@ ECHO_API_TOKEN="your_token" ECHO_API_BASE_URL="http://localhost:3000" npm run st
|
|
|
185
189
|
* **`search_memories_by_keywords`**: Retrieve memories by matching the `keys` field.
|
|
186
190
|
* **`search_others_memories`**: Search other users' public memories through MemoryFeed public search.
|
|
187
191
|
* **`delete_memory`**: Delete a single personal memory through a two-step confirmation flow. First call with `memoryId` only to preview the target and receive `confirmationToken`; after the user explicitly confirms, call again with `confirmed: true` and that exact token. This deletes the memory row only and preserves raw `source_of_truth` conversation records.
|
|
192
|
+
* **`echomem_update_status`**: Check the installed bridge against the latest published npm version. Works without login, uses cached background checks in normal operation, and returns the update command to show the user when a newer bridge exists.
|
|
188
193
|
* **`echo_context_health`**: Return the local Codex/Claude context-health score as markdown. Works without login and uploads no transcript content.
|
|
189
194
|
|
|
190
195
|
Legacy aliases are preserved for compatibility:
|
package/dist/hud/capsule.js
CHANGED
|
@@ -9,10 +9,13 @@ import { formatTokens } from "./metric.js";
|
|
|
9
9
|
const MAX_TURN_CHARS = 280;
|
|
10
10
|
const MAX_FILES = 10;
|
|
11
11
|
export function buildCapsuleText(score) {
|
|
12
|
-
const { turns, edited } = scanSession(score.sourcePath);
|
|
12
|
+
const { turns, edited, cwd } = scanSession(score.sourcePath);
|
|
13
13
|
const goal = turns[0];
|
|
14
14
|
const lastAsk = turns.length > 1 ? turns[turns.length - 1] : undefined;
|
|
15
|
-
|
|
15
|
+
// Normalize every path to repo-relative so "editing" (recorded absolute) and "read"
|
|
16
|
+
// (recorded relative) dedupe correctly and the capsule isn't bloated by the cwd prefix.
|
|
17
|
+
const editedRel = edited.map((f) => relToCwd(f, cwd));
|
|
18
|
+
const reads = readFiles(score, editedRel, cwd);
|
|
16
19
|
const sat = score.saturationPct !== null
|
|
17
20
|
? ` · ${score.saturationPct}% of ${formatTokens(score.modelContextWindow || 0)} window`
|
|
18
21
|
: "";
|
|
@@ -25,21 +28,23 @@ export function buildCapsuleText(score) {
|
|
|
25
28
|
];
|
|
26
29
|
if (lastAsk)
|
|
27
30
|
lines.push("", "## Most recent instruction", lastAsk);
|
|
28
|
-
if (
|
|
29
|
-
lines.push("", "## Files being edited", ...
|
|
31
|
+
if (editedRel.length)
|
|
32
|
+
lines.push("", "## Files being edited", ...editedRel.slice(0, MAX_FILES).map((f) => `- ${f}`));
|
|
30
33
|
if (reads.length)
|
|
31
34
|
lines.push("", "## Other files read", ...reads.slice(0, MAX_FILES).map((f) => `- ${f}`));
|
|
32
35
|
lines.push("", "## Where things stand", `- Turns ${score.turn} · reads ${score.reads} · edits ${score.stats?.patchEdits ?? 0} · compactions ${score.stats?.compactMarkers ?? 0}`, `- Tracked dead-weight ≥ ${formatTokens(score.pollutionTok)} (${score.pollutionPct}% pollution, lower bound)`, "", "---", "Start a fresh session and paste this capsule (or call search_memories) so the new window begins clean — you keep the goal, the working set, and your last instruction without re-reading everything. This is a clean recompose, not a provider compaction.");
|
|
33
36
|
return lines.join("\n");
|
|
34
37
|
}
|
|
35
38
|
// Read files the scorer already tracked, minus anything we know was edited (shown separately).
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
// Both sides are relativized to cwd first so the edited/read dedupe actually matches.
|
|
40
|
+
function readFiles(score, editedRel, cwd) {
|
|
41
|
+
const editedSet = new Set(editedRel);
|
|
38
42
|
const seen = new Set();
|
|
39
43
|
for (const files of Object.values(score.filesByTool || {})) {
|
|
40
44
|
for (const file of files) {
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
const rel = relToCwd(file, cwd);
|
|
46
|
+
if (rel && !editedSet.has(rel))
|
|
47
|
+
seen.add(rel);
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
return [...seen];
|
|
@@ -47,45 +52,83 @@ function readFiles(score, edited) {
|
|
|
47
52
|
function scanSession(file) {
|
|
48
53
|
const turns = [];
|
|
49
54
|
const edited = new Set();
|
|
55
|
+
let cwd = "";
|
|
50
56
|
let records = [];
|
|
51
57
|
try {
|
|
52
58
|
records = readJsonl(file);
|
|
53
59
|
}
|
|
54
60
|
catch {
|
|
55
|
-
return { turns, edited: [] };
|
|
61
|
+
return { turns, edited: [], cwd };
|
|
56
62
|
}
|
|
57
63
|
for (const record of records) {
|
|
58
64
|
if (!isRecord(record))
|
|
59
65
|
continue;
|
|
66
|
+
if (!cwd)
|
|
67
|
+
cwd = recordCwd(record);
|
|
60
68
|
const text = userTextFromRecord(record);
|
|
61
69
|
if (text)
|
|
62
70
|
turns.push(text);
|
|
63
71
|
collectEdited(record, edited);
|
|
64
72
|
}
|
|
65
|
-
return { turns, edited: [...edited] };
|
|
73
|
+
return { turns, edited: [...edited], cwd };
|
|
66
74
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
function recordCwd(record) {
|
|
76
|
+
if (typeof record.cwd === "string")
|
|
77
|
+
return record.cwd;
|
|
78
|
+
const payload = isRecord(record.payload) ? record.payload : null;
|
|
79
|
+
if (payload && typeof payload.cwd === "string")
|
|
80
|
+
return payload.cwd;
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
function relToCwd(file, cwd) {
|
|
84
|
+
if (!file)
|
|
85
|
+
return file;
|
|
86
|
+
if (cwd && (file === cwd || file.startsWith(`${cwd}/`)))
|
|
87
|
+
return file.slice(cwd.length + 1) || file;
|
|
88
|
+
return file;
|
|
89
|
+
}
|
|
90
|
+
// Reads the user's real turns, matching the same sources assembleCodex/assembleClaude use so the
|
|
91
|
+
// goal/last-instruction can't be the injected preamble:
|
|
92
|
+
// - Codex: the `user_message` EVENT (payload.message is a plain string). The AGENTS.md preamble is a
|
|
93
|
+
// separate `message`/role:user ITEM, so reading events excludes it by construction — no blocklist.
|
|
94
|
+
// - Claude: the top-level `type:"user"` turn with message.content text blocks.
|
|
95
|
+
// Degrades to nothing rather than throwing, so the capsule still renders from the working set + health.
|
|
70
96
|
function userTextFromRecord(record) {
|
|
71
97
|
const payload = isRecord(record.payload) ? record.payload : record;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
role = record.type;
|
|
80
|
-
const ptype = typeof payload.type === "string" ? payload.type : "";
|
|
81
|
-
if (role !== "user" && ptype !== "user_message")
|
|
98
|
+
// Codex user input.
|
|
99
|
+
if (payload.type === "user_message" && typeof payload.message === "string") {
|
|
100
|
+
return finishTurn(clean(stripCodexScaffolding(payload.message)));
|
|
101
|
+
}
|
|
102
|
+
// Claude user turn (top-level type:"user"). Codex `message`/role:user items are NOT type:"user",
|
|
103
|
+
// so they never reach here — that's what keeps the AGENTS.md item and duplicate turns out.
|
|
104
|
+
if (record.type !== "user")
|
|
82
105
|
return null;
|
|
83
|
-
const
|
|
106
|
+
const message = isRecord(record.message) ? record.message : null;
|
|
107
|
+
const raw = message ? message.content : undefined;
|
|
84
108
|
const text = clean(flatten(raw));
|
|
85
|
-
if (!text || text.startsWith("<")
|
|
109
|
+
if (!text || text.startsWith("<"))
|
|
110
|
+
return null;
|
|
111
|
+
if (isInstructionsPreamble(text))
|
|
112
|
+
return null; // Claude project-rules / caveat injections
|
|
113
|
+
return finishTurn(text);
|
|
114
|
+
}
|
|
115
|
+
function finishTurn(text) {
|
|
116
|
+
if (!text || text.length < 3)
|
|
86
117
|
return null;
|
|
87
118
|
return text.length > MAX_TURN_CHARS ? `${text.slice(0, MAX_TURN_CHARS)}…` : text;
|
|
88
119
|
}
|
|
120
|
+
// Strip Codex's synthetic wrapper blocks from a user message (mirrors migrate.ts cleanCodexUser).
|
|
121
|
+
function stripCodexScaffolding(msg) {
|
|
122
|
+
return String(msg || "")
|
|
123
|
+
.replace(/<(environment_context|user_instructions|permissions|app-context)>[\s\S]*?<\/\1>/g, "")
|
|
124
|
+
.trim();
|
|
125
|
+
}
|
|
126
|
+
// Claude-side injected preambles (project rules / harness caveats). Anchored to the START so a real
|
|
127
|
+
// instruction that merely mentions these words isn't dropped. Codex's AGENTS.md is handled
|
|
128
|
+
// structurally above, so it isn't listed here.
|
|
129
|
+
function isInstructionsPreamble(text) {
|
|
130
|
+
return /^#\s*claudeMd\b/i.test(text) || text.startsWith("Caveat:");
|
|
131
|
+
}
|
|
89
132
|
function collectEdited(record, set) {
|
|
90
133
|
const payload = isRecord(record.payload) ? record.payload : record;
|
|
91
134
|
if (payload.type === "patch_apply_end" && isRecord(payload.changes)) {
|
package/dist/hud/metric.js
CHANGED
|
@@ -7,6 +7,10 @@ export const BUCKETS = {
|
|
|
7
7
|
};
|
|
8
8
|
const SHELL_READ_BINS = new Set(["cat", "head", "tail", "sed", "nl", "less", "more", "bat"]);
|
|
9
9
|
const EXT = /\.[A-Za-z0-9]{1,8}$/;
|
|
10
|
+
// A re-read counts as redundant only if a prior read substantially re-covers THIS read. A shared
|
|
11
|
+
// boundary line (sequential paging, e.g. sed 1,260p then 260,620p) is not a re-read; requiring ≥50%
|
|
12
|
+
// of the new range to be already-seen keeps paging out while still catching genuine sub-range re-reads.
|
|
13
|
+
const REDUNDANT_OVERLAP_FRACTION = 0.5;
|
|
10
14
|
export function newMetricState() {
|
|
11
15
|
return {
|
|
12
16
|
turn: 0,
|
|
@@ -45,7 +49,13 @@ export function recordRead(state, file, start = 1, end = 1e9) {
|
|
|
45
49
|
const tokens = estimateReadTokens(safeStart, safeEnd);
|
|
46
50
|
const previous = state.readHist.get(file) || [];
|
|
47
51
|
const lastEdit = state.editTurn.get(file) ?? -1;
|
|
48
|
-
const
|
|
52
|
+
const newLines = Math.max(1, safeEnd - safeStart + 1);
|
|
53
|
+
const redundant = previous.some((read) => {
|
|
54
|
+
if (read.turn < lastEdit)
|
|
55
|
+
return false; // an edit since this read invalidated it — re-read is fresh
|
|
56
|
+
const overlap = Math.min(safeEnd, read.end) - Math.max(safeStart, read.start) + 1;
|
|
57
|
+
return overlap > 0 && overlap / newLines >= REDUNDANT_OVERLAP_FRACTION;
|
|
58
|
+
});
|
|
49
59
|
state.reads += 1;
|
|
50
60
|
if (redundant) {
|
|
51
61
|
addBucket(state, BUCKETS.rangeRedundant, tokens, 1);
|
|
@@ -132,9 +142,12 @@ function estimateReadTokens(start, end) {
|
|
|
132
142
|
return Math.min(8000, Math.max(40, lines * 12));
|
|
133
143
|
}
|
|
134
144
|
function qualityColor(pollutionPct, saturationPct) {
|
|
135
|
-
if (pollutionPct
|
|
145
|
+
if (pollutionPct >= 35 ||
|
|
146
|
+
(saturationPct !== null && saturationPct >= 95) ||
|
|
147
|
+
(saturationPct !== null && saturationPct >= 85 && pollutionPct >= 20)) {
|
|
136
148
|
return "red";
|
|
137
|
-
|
|
149
|
+
}
|
|
150
|
+
if (pollutionPct >= 18 || (saturationPct !== null && saturationPct >= 75))
|
|
138
151
|
return "amber";
|
|
139
152
|
return "green";
|
|
140
153
|
}
|
package/dist/hud/monitor.js
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
import { adapterList } from "./adapters.js";
|
|
4
|
-
import {
|
|
6
|
+
import { homePath, newestFile, walkFiles } from "./fs.js";
|
|
7
|
+
const LIVE_WINDOW_MS = 45_000;
|
|
8
|
+
const ONGOING_WINDOW_MS = 300_000; // a thread counts as "ongoing" if its log was written in the last 5 min
|
|
9
|
+
const FOCUS_GRACE_MS = 8_000; // keep the last-known frontmost client this long when detection momentarily misses
|
|
5
10
|
export class HudMonitor extends EventEmitter {
|
|
6
11
|
mode;
|
|
7
12
|
pollMs;
|
|
8
13
|
timer = null;
|
|
9
14
|
signatures = new Map();
|
|
10
15
|
scores = new Map();
|
|
16
|
+
mtimes = new Map();
|
|
17
|
+
labelCache = new Map();
|
|
11
18
|
missing = [];
|
|
12
19
|
frontmostCheckedAt = 0;
|
|
20
|
+
frontmostSeenAt = 0;
|
|
13
21
|
frontmostClient = null;
|
|
14
22
|
lastActiveClient = null;
|
|
23
|
+
threadCounts = {};
|
|
24
|
+
threadCountsAt = 0;
|
|
15
25
|
constructor(mode = "auto", pollMs = 750) {
|
|
16
26
|
super();
|
|
17
27
|
this.mode = mode;
|
|
@@ -30,45 +40,116 @@ export class HudMonitor extends EventEmitter {
|
|
|
30
40
|
this.timer = null;
|
|
31
41
|
}
|
|
32
42
|
snapshot() {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
const focused = this.frontmostPreferredClient();
|
|
44
|
+
this.refreshThreadCounts();
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const sessions = [...this.scores.values()].map((score) => {
|
|
47
|
+
// Liveness uses file mtime (real last write), not score.updatedAt — the Claude cache stamps
|
|
48
|
+
// updatedAt = now on every read, which would make an idle session look permanently live.
|
|
49
|
+
const mtimeMs = this.mtimes.get(score.client) ?? (Date.parse(score.updatedAt) || now);
|
|
50
|
+
const lastActiveMs = Math.max(0, now - mtimeMs);
|
|
51
|
+
return {
|
|
52
|
+
...score,
|
|
53
|
+
live: lastActiveMs < LIVE_WINDOW_MS,
|
|
54
|
+
focused: focused === score.client,
|
|
55
|
+
label: this.labelFor(score),
|
|
56
|
+
lastActiveMs,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
// focused first, then live (most-recent first), then idle (most-recent first).
|
|
60
|
+
sessions.sort((a, b) => {
|
|
61
|
+
if (a.focused !== b.focused)
|
|
62
|
+
return a.focused ? -1 : 1;
|
|
63
|
+
if (a.live !== b.live)
|
|
64
|
+
return a.live ? -1 : 1;
|
|
65
|
+
return a.lastActiveMs - b.lastActiveMs;
|
|
40
66
|
});
|
|
41
67
|
return {
|
|
42
68
|
mode: this.mode,
|
|
43
|
-
active:
|
|
44
|
-
scores,
|
|
69
|
+
active: sessions[0] || null,
|
|
70
|
+
scores: sessions,
|
|
71
|
+
sessions,
|
|
72
|
+
threadCounts: this.threadCounts,
|
|
45
73
|
missing: this.missing,
|
|
46
74
|
updatedAt: new Date().toISOString(),
|
|
47
75
|
};
|
|
48
76
|
}
|
|
77
|
+
labelFor(score) {
|
|
78
|
+
const cached = this.labelCache.get(score.sourcePath);
|
|
79
|
+
if (cached)
|
|
80
|
+
return cached;
|
|
81
|
+
const label = sessionLabel(score.sourcePath, score.client);
|
|
82
|
+
this.labelCache.set(score.sourcePath, label);
|
|
83
|
+
return label;
|
|
84
|
+
}
|
|
49
85
|
frontmostPreferredClient() {
|
|
50
86
|
if (this.mode !== "auto" && this.mode !== "both")
|
|
51
87
|
return null;
|
|
52
88
|
const now = Date.now();
|
|
53
89
|
if (now - this.frontmostCheckedAt > 1500) {
|
|
54
90
|
this.frontmostCheckedAt = now;
|
|
55
|
-
|
|
91
|
+
const detected = detectFrontmostClient();
|
|
92
|
+
if (detected) {
|
|
93
|
+
// Positive detection wins immediately (real focus switch, e.g. Codex → Claude Desktop).
|
|
94
|
+
this.frontmostClient = detected;
|
|
95
|
+
this.frontmostSeenAt = now;
|
|
96
|
+
}
|
|
97
|
+
else if (now - this.frontmostSeenAt > FOCUS_GRACE_MS) {
|
|
98
|
+
// Detection missed (osascript timeout, or a non-agent app like a browser is front). Hold the
|
|
99
|
+
// last-known focus for a grace window so the active slot doesn't flip to a background client
|
|
100
|
+
// just because it's writing — then release once the grace expires.
|
|
101
|
+
this.frontmostClient = null;
|
|
102
|
+
}
|
|
56
103
|
}
|
|
57
104
|
return this.frontmostClient && this.scores.has(this.frontmostClient) ? this.frontmostClient : null;
|
|
58
105
|
}
|
|
106
|
+
// Count each client's "ongoing" threads = session files written within ONGOING_WINDOW_MS. Throttled,
|
|
107
|
+
// since it walks every session file per client (cheap at a few-second cadence, wasteful at 750ms).
|
|
108
|
+
refreshThreadCounts() {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
if (now - this.threadCountsAt < 2500 && Object.keys(this.threadCounts).length)
|
|
111
|
+
return;
|
|
112
|
+
this.threadCountsAt = now;
|
|
113
|
+
const counts = {};
|
|
114
|
+
for (const adapter of adapterList(this.mode)) {
|
|
115
|
+
let n = 0;
|
|
116
|
+
for (const file of adapter.findAll()) {
|
|
117
|
+
try {
|
|
118
|
+
if (now - fs.statSync(file).mtimeMs < ONGOING_WINDOW_MS)
|
|
119
|
+
n += 1;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* ignore files that vanish mid-walk */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
counts[adapter.client] = n;
|
|
126
|
+
}
|
|
127
|
+
this.threadCounts = counts;
|
|
128
|
+
}
|
|
59
129
|
tick() {
|
|
60
130
|
const missing = [];
|
|
61
131
|
let changed = false;
|
|
62
132
|
for (const adapter of adapterList(this.mode)) {
|
|
63
133
|
const file = adapter.findActive();
|
|
64
|
-
|
|
134
|
+
let stat = null;
|
|
135
|
+
if (file) {
|
|
136
|
+
try {
|
|
137
|
+
stat = fs.statSync(file);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
stat = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!file || !stat) {
|
|
65
144
|
missing.push(adapter.client);
|
|
66
145
|
if (this.scores.delete(adapter.client))
|
|
67
146
|
changed = true;
|
|
147
|
+
this.mtimes.delete(adapter.client);
|
|
68
148
|
continue;
|
|
69
149
|
}
|
|
70
|
-
|
|
71
|
-
|
|
150
|
+
this.mtimes.set(adapter.client, liveMtime(adapter.client, file, stat.mtimeMs));
|
|
151
|
+
const signature = `${file}:${stat.size}:${stat.mtimeMs}`;
|
|
152
|
+
if (signature === this.signatures.get(adapter.client))
|
|
72
153
|
continue;
|
|
73
154
|
this.signatures.set(adapter.client, signature);
|
|
74
155
|
try {
|
|
@@ -104,3 +185,60 @@ function detectFrontmostClient() {
|
|
|
104
185
|
}
|
|
105
186
|
return null;
|
|
106
187
|
}
|
|
188
|
+
function defaultLabel(client) {
|
|
189
|
+
if (client === "codex")
|
|
190
|
+
return "Codex";
|
|
191
|
+
if (client === "claude-code")
|
|
192
|
+
return "Claude Code";
|
|
193
|
+
return "Claude Desktop";
|
|
194
|
+
}
|
|
195
|
+
// Recognizable label = basename of the session's cwd. Both Codex rollouts and Claude transcripts
|
|
196
|
+
// carry a "cwd" field; the Claude Code active source is the echo-ctx cache (<sessionId>.json, no cwd),
|
|
197
|
+
// so resolve its transcript by sessionId first. Falls back to the client name when cwd is absent.
|
|
198
|
+
function sessionLabel(file, client) {
|
|
199
|
+
const target = resolveClaudeTranscript(file) ?? file;
|
|
200
|
+
const cwd = peekCwd(target);
|
|
201
|
+
return cwd ? path.basename(cwd) : defaultLabel(client);
|
|
202
|
+
}
|
|
203
|
+
// The Claude Code active source is the echo-ctx cache (<sessionId>.json), whose mtime only bumps when
|
|
204
|
+
// the statusline re-renders — stale during a long turn. The transcript grows every tool call, so it's
|
|
205
|
+
// the true liveness signal. Take the fresher of the two. Cached per cache-file (paths are stable).
|
|
206
|
+
function liveMtime(client, file, cacheMtimeMs) {
|
|
207
|
+
const transcript = client === "claude-code" ? resolveClaudeTranscript(file) : null;
|
|
208
|
+
if (!transcript)
|
|
209
|
+
return cacheMtimeMs;
|
|
210
|
+
try {
|
|
211
|
+
return Math.max(cacheMtimeMs, fs.statSync(transcript).mtimeMs);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return cacheMtimeMs;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const transcriptCache = new Map();
|
|
218
|
+
function resolveClaudeTranscript(cacheFile) {
|
|
219
|
+
if (!cacheFile.endsWith(".json") || !cacheFile.includes(`${path.sep}echo-ctx${path.sep}`))
|
|
220
|
+
return null;
|
|
221
|
+
if (transcriptCache.has(cacheFile))
|
|
222
|
+
return transcriptCache.get(cacheFile) ?? null;
|
|
223
|
+
const sessionId = path.basename(cacheFile, ".json");
|
|
224
|
+
const transcript = newestFile(walkFiles(homePath(".claude", "projects"), (f) => path.basename(f) === `${sessionId}.jsonl`));
|
|
225
|
+
transcriptCache.set(cacheFile, transcript);
|
|
226
|
+
return transcript;
|
|
227
|
+
}
|
|
228
|
+
function peekCwd(file) {
|
|
229
|
+
try {
|
|
230
|
+
const fd = fs.openSync(file, "r");
|
|
231
|
+
try {
|
|
232
|
+
const buf = Buffer.alloc(256 * 1024);
|
|
233
|
+
const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
234
|
+
const match = buf.toString("utf8", 0, bytes).match(/"cwd"\s*:\s*"([^"]+)"/);
|
|
235
|
+
return match ? match[1].replace(/\\\//g, "/") : null;
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
fs.closeSync(fd);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|