@luanpdd/kit-mcp 1.5.3 → 1.6.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.
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ // hook-version: 1.6.0
3
+ // kit-mcp · Sidecar Tool Publisher (PostToolUse)
4
+ //
5
+ // Publishes every Claude Code tool invocation to the kit-mcp sidecar so the
6
+ // localhost UI shows real-time activity from this IDE — including edits,
7
+ // reads, bash, agent spawns, MCP calls. Closes the gap where the sidecar
8
+ // previously only saw `kit sync`/`reverse-sync`/`gates` operations.
9
+ //
10
+ // Pipeline: PostToolUse hook → reads stdin envelope → discovers sidecar
11
+ // lockfile (per project_root) → POST /publish → fire-and-forget.
12
+ //
13
+ // SOFT failure: any error logs to stderr and exits 0. Never blocks the user.
14
+ //
15
+ // Module format: ESM (package.json "type": "module"). Stays compatible whether
16
+ // run from inside the kit-mcp repo or from a user project.
17
+ //
18
+ // Enable via `~/.claude/settings.json` (or per-project `.claude/settings.json`):
19
+ // {
20
+ // "hooks": {
21
+ // "PostToolUse": [{
22
+ // "matcher": "*",
23
+ // "hooks": [{
24
+ // "type": "command",
25
+ // "command": "node /abs/path/to/sidecar-tool-publisher.js"
26
+ // }]
27
+ // }]
28
+ // }
29
+ // }
30
+
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+ import http from 'node:http';
35
+ import crypto from 'node:crypto';
36
+ import process from 'node:process';
37
+
38
+ let input = '';
39
+ const stdinTimeout = setTimeout(() => process.exit(0), 1500);
40
+ process.stdin.setEncoding('utf8');
41
+ process.stdin.on('data', (chunk) => { input += chunk; });
42
+ process.stdin.on('end', () => {
43
+ clearTimeout(stdinTimeout);
44
+ try {
45
+ const data = input ? JSON.parse(input) : {};
46
+ const toolName = data.tool_name || data.toolName || 'unknown';
47
+ const projectRoot = data.project_root || data.cwd || process.cwd();
48
+
49
+ debugLog({ phase: 'received', toolName, projectRoot, cwd: process.cwd(), keys: Object.keys(data) });
50
+
51
+ // Try requested projectRoot first; if no lockfile found, scan all
52
+ // kit-mcp-ui-*.lock files in tmpdir and pick one that healthz-responds.
53
+ // This makes the hook resilient to projectRoot mismatch (case, separators,
54
+ // trailing slash, parent-of-project edits, etc).
55
+ let port = readSidecarPort(projectRoot);
56
+ if (!port) port = scanAnyRunningSidecar();
57
+ if (!port) {
58
+ debugLog({ phase: 'no_sidecar', projectRoot });
59
+ process.exit(0);
60
+ }
61
+
62
+ const payload = {
63
+ tool: toolName,
64
+ sessionId: data.session_id || data.sessionId || null,
65
+ durationMs: typeof data.duration_ms === 'number' ? data.duration_ms : null,
66
+ argsSummary: summarizeArgs(data.tool_input),
67
+ source: detectSource(),
68
+ };
69
+
70
+ const event = {
71
+ type: 'tool_invocation',
72
+ ts: Date.now(),
73
+ runId: null,
74
+ payload,
75
+ };
76
+
77
+ publish(port, event);
78
+ process.exit(0);
79
+ } catch (err) {
80
+ process.stderr.write(`[sidecar-tool-publisher] ${err.message}\n`);
81
+ process.exit(0);
82
+ }
83
+ });
84
+
85
+ function readSidecarPort(projectRoot) {
86
+ // Mirror src/ui/lockfile.js#lockPathFor (sha1(projectRoot).slice(0,16))
87
+ try {
88
+ const hash = crypto.createHash('sha1').update(projectRoot).digest('hex').slice(0, 16);
89
+ const lockPath = path.join(os.tmpdir(), `kit-mcp-ui-${hash}.lock`);
90
+ const raw = fs.readFileSync(lockPath, 'utf8');
91
+ const lock = JSON.parse(raw);
92
+ return typeof lock.port === 'number' ? lock.port : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ // Scan os.tmpdir() for any kit-mcp-ui-*.lock and return the first valid port.
99
+ // Used as a fallback when projectRoot doesn't match any known lockfile (case
100
+ // variants, separator differences, parent-dir edits, etc).
101
+ function scanAnyRunningSidecar() {
102
+ try {
103
+ const dir = os.tmpdir();
104
+ const entries = fs.readdirSync(dir);
105
+ for (const name of entries) {
106
+ if (!/^kit-mcp-ui-[0-9a-f]{16}\.lock$/.test(name)) continue;
107
+ try {
108
+ const raw = fs.readFileSync(path.join(dir, name), 'utf8');
109
+ const lock = JSON.parse(raw);
110
+ if (typeof lock.port === 'number' && typeof lock.pid === 'number') {
111
+ // Best-effort liveness check.
112
+ try { process.kill(lock.pid, 0); return lock.port; } catch { /* dead */ }
113
+ }
114
+ } catch { /* skip unreadable */ }
115
+ }
116
+ } catch { /* tmpdir unreadable */ }
117
+ return null;
118
+ }
119
+
120
+ function debugLog(obj) {
121
+ if (process.env.KIT_MCP_HOOK_DEBUG !== '1') return;
122
+ try {
123
+ const line = JSON.stringify({ ts: Date.now(), ...obj }) + '\n';
124
+ fs.appendFileSync(path.join(os.tmpdir(), 'kit-mcp-hook.log'), line);
125
+ } catch { /* noop */ }
126
+ }
127
+
128
+ function summarizeArgs(args) {
129
+ if (!args || typeof args !== 'object') return null;
130
+ const out = {};
131
+ if (typeof args.command === 'string') out.command = truncate(args.command, 120);
132
+ if (typeof args.file_path === 'string') out.file_path = truncate(args.file_path, 200);
133
+ if (typeof args.pattern === 'string') out.pattern = truncate(args.pattern, 80);
134
+ if (typeof args.url === 'string') out.url = truncate(args.url, 120);
135
+ if (typeof args.description === 'string') out.description = truncate(args.description, 80);
136
+ if (Array.isArray(args.actions)) out.action_count = args.actions.length;
137
+ return Object.keys(out).length ? out : null;
138
+ }
139
+
140
+ function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
141
+
142
+ function detectSource() {
143
+ const ide = detectIde();
144
+ const pid = process.ppid || process.pid;
145
+ const id = `${ide}:${pid}`;
146
+ return {
147
+ id,
148
+ ide,
149
+ pid,
150
+ hostname: os.hostname(),
151
+ };
152
+ }
153
+
154
+ function detectIde() {
155
+ if (process.env.CLAUDE_CODE_VERSION || process.env.CLAUDECODE) return 'claude-code';
156
+ if (process.env.CURSOR_TRACE_ID) return 'cursor';
157
+ if (process.env.TERM_PROGRAM === 'vscode') return 'vscode';
158
+ if (process.env.JETBRAINS_IDE) return 'jetbrains';
159
+ return 'unknown';
160
+ }
161
+
162
+ function publish(port, event) {
163
+ const body = JSON.stringify(event);
164
+ const req = http.request({
165
+ method: 'POST',
166
+ host: '127.0.0.1',
167
+ port,
168
+ path: '/publish',
169
+ agent: false,
170
+ headers: {
171
+ host: `127.0.0.1:${port}`,
172
+ 'content-type': 'application/json',
173
+ 'content-length': Buffer.byteLength(body, 'utf8'),
174
+ origin: `http://127.0.0.1:${port}`,
175
+ connection: 'close',
176
+ },
177
+ }, (res) => { res.resume(); });
178
+ req.on('error', () => { /* fire-and-forget */ });
179
+ req.setTimeout(800, () => { try { req.destroy(); } catch (_) { /* noop */ } });
180
+ req.write(body);
181
+ req.end();
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luanpdd/kit-mcp",
3
- "version": "1.5.3",
3
+ "version": "1.6.0",
4
4
  "description": "Generic infrastructure to ship YOUR personal kit of agents/commands/skills as an MCP server, with cross-IDE sync (Claude Code, Cursor, Codex, Gemini, Windsurf, Antigravity, Copilot, Trae).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,7 +44,8 @@
44
44
  "smoke": "node bin/cli.js kit list-agents | head -5",
45
45
  "test": "node test/run.mjs test/unit",
46
46
  "test:integration": "node test/run.mjs test/integration",
47
- "test:all": "node test/run.mjs test"
47
+ "test:all": "node test/run.mjs test",
48
+ "prepublishOnly": "node test/run.mjs test/unit && node test/run.mjs test/integration"
48
49
  },
49
50
  "dependencies": {
50
51
  "@inquirer/prompts": "^8.4.2",
package/src/core/kit.js CHANGED
@@ -10,6 +10,12 @@ import { fileURLToPath } from 'node:url';
10
10
  const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = path.dirname(__filename);
12
12
 
13
+ // PERF-02: Frontmatter regexes compiled once at module load (was being recompiled
14
+ // on every readMdDir / readSkillsDir entry — 60+ times per listKit call).
15
+ const FRONTMATTER_SPLIT_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
16
+ const FRONTMATTER_RAW_RE = /^(---\r?\n[\s\S]*?\r?\n---\r?\n?)/;
17
+ const YAML_KEY_RE = /^([A-Za-z0-9_-]+):\s*(.*)$/;
18
+
13
19
  // Resolution order for the kit root (re-evaluated on each call so env-var
14
20
  // overrides set after module load — e.g. by the CLI preAction hook — work):
15
21
  // 1. explicit `kitRoot` opt passed by caller
@@ -22,15 +28,30 @@ export function resolveKitRoot(kitRoot) {
22
28
  return BUNDLED_KIT_ROOT;
23
29
  }
24
30
 
31
+ // PERF-01: TTL cache for listKit output. Repeated calls within KIT_CACHE_TTL_MS
32
+ // return the cached value — sync/reverse-sync/MCP list-* tools used to walk the
33
+ // disk on every invocation. Trade-off: callers that edit kit/ inside the same
34
+ // process may see stale data for up to 30s. Acceptable for MCP/CLI ergonomics.
35
+ const KIT_CACHE_TTL_MS = 30_000;
36
+ const kitCache = new Map(); // kitRoot -> { value, ts }
37
+
38
+ export function clearKitCache() { kitCache.clear(); }
39
+
25
40
  export async function listKit(kitRoot) {
26
41
  kitRoot = resolveKitRoot(kitRoot);
42
+ const cached = kitCache.get(kitRoot);
43
+ if (cached && Date.now() - cached.ts < KIT_CACHE_TTL_MS) {
44
+ return cached.value;
45
+ }
27
46
  const [agents, commands, skills, skillsExtras] = await Promise.all([
28
47
  readMdDir(path.join(kitRoot, 'agents'), 'agent'),
29
48
  readMdDir(path.join(kitRoot, 'commands'), 'command'),
30
49
  readSkillsDir(path.join(kitRoot, 'skills')),
31
50
  readSkillsDir(path.join(kitRoot, 'skills-extras')).catch(() => []),
32
51
  ]);
33
- return { agents, commands, skills, skillsExtras, kitRoot };
52
+ const value = { agents, commands, skills, skillsExtras, kitRoot };
53
+ kitCache.set(kitRoot, { value, ts: Date.now() });
54
+ return value;
34
55
  }
35
56
 
36
57
  async function readMdDir(dir, kind) {
@@ -95,13 +116,13 @@ async function readSkillsDir(dir) {
95
116
  // Good enough for our SKILL.md / agent.md headers.
96
117
 
97
118
  function splitFrontmatter(raw) {
98
- const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
119
+ const m = raw.match(FRONTMATTER_SPLIT_RE);
99
120
  if (!m) return { frontmatter: null, body: raw };
100
121
  return { frontmatter: parseLooseYaml(m[1]), body: m[2] };
101
122
  }
102
123
 
103
124
  function matchFrontmatterRaw(raw) {
104
- const m = raw.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)/);
125
+ const m = raw.match(FRONTMATTER_RAW_RE);
105
126
  return m ? m[1] : '';
106
127
  }
107
128
 
@@ -111,7 +132,7 @@ function parseLooseYaml(text) {
111
132
  let i = 0;
112
133
  while (i < lines.length) {
113
134
  const line = lines[i];
114
- const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
135
+ const m = line.match(YAML_KEY_RE);
115
136
  if (!m) { i++; continue; }
116
137
  const key = m[1];
117
138
  let val = m[2];
@@ -26,7 +26,8 @@ export async function detectReverse(targetId, opts = {}) {
26
26
  const target = getTarget(targetId);
27
27
  const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
28
28
  const kitRoot = resolveKitRoot(opts.kitRoot);
29
- const kit = await listKit(kitRoot);
29
+ // PERF-03: accept a pre-loaded kit; reduces sync+reverse-sync from 2 walks to 1.
30
+ const kit = opts.kit ?? await listKit(kitRoot);
30
31
 
31
32
  const candidates = [];
32
33
 
package/src/core/sync.js CHANGED
@@ -26,7 +26,9 @@ export async function syncTo(targetId, opts = {}) {
26
26
  const dryRun = !!opts.dryRun;
27
27
  const onProgress = opts.onProgress ?? (() => {});
28
28
 
29
- const kit = await listKit(kitRoot);
29
+ // PERF-03: accept a pre-loaded kit to avoid re-walking the disk when callers
30
+ // already have one in hand (CLI sync that follows reverse-sync detect, etc).
31
+ const kit = opts.kit ?? await listKit(kitRoot);
30
32
  const ops = [];
31
33
 
32
34
  if (target.rules) {
@@ -99,6 +101,16 @@ export async function syncTo(targetId, opts = {}) {
99
101
  return { target: targetId, mode, projectRoot, kitRoot, written: ops.map(o => o.path), dryRun };
100
102
  }
101
103
 
104
+ // SEC-02: walkTree refuses entries whose normalized rel-path escapes the root or
105
+ // is absolute, blocking path-traversal via maliciously-named files in mode=copy.
106
+ function isSafeRel(rel) {
107
+ if (!rel) return false;
108
+ const norm = path.posix.normalize(rel.replaceAll('\\', '/'));
109
+ if (norm.startsWith('..') || norm.startsWith('/') || /^[A-Za-z]:/.test(norm)) return false;
110
+ if (norm.split('/').some((seg) => seg === '..')) return false;
111
+ return true;
112
+ }
113
+
102
114
  async function walkTree(dir) {
103
115
  const out = [];
104
116
  async function visit(current, relPrefix) {
@@ -108,6 +120,12 @@ async function walkTree(dir) {
108
120
  for (const e of entries) {
109
121
  const abs = path.join(current, e.name);
110
122
  const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
123
+ // SEC-02: reject names that would compose into path-traversal.
124
+ if (!isSafeRel(rel)) {
125
+ const err = new Error(`walkTree refuses unsafe path: ${rel}`);
126
+ err.code = 'EUNSAFEPATH';
127
+ throw err;
128
+ }
111
129
  if (e.isDirectory()) {
112
130
  await visit(abs, rel);
113
131
  } else if (e.isFile()) {
@@ -233,28 +251,37 @@ See: [\`${rel}\`](${rel})
233
251
  `;
234
252
  }
235
253
 
236
- function buildAggregatedRules(kit, target, kitRoot) {
254
+ // TOK-02: produce summary-only listings. Full descriptions live in each item's
255
+ // own file under kit/ — duplicating them here costs tokens in every Claude
256
+ // Code session. Cap each line at ~80 chars; users can `kit get <name>` for the
257
+ // full description.
258
+ const SUMMARY_MAX_CHARS = 80;
259
+ function summarize(desc) {
260
+ if (!desc) return '';
261
+ const flat = desc.replace(/\s+/g, ' ').trim();
262
+ if (flat.length <= SUMMARY_MAX_CHARS) return flat;
263
+ return flat.slice(0, SUMMARY_MAX_CHARS - 1) + '…';
264
+ }
265
+
266
+ function buildAggregatedRules(kit, target /* , kitRoot */) {
237
267
  const lines = [
238
268
  STUB_MARKER,
239
269
  '',
240
- '# Personal kit instructions',
241
- '',
242
- '> Auto-generated by kit-mcp. Edit the canonical source files under `kit/` —',
243
- '> running `kit sync ' + (target.label ? '<target>' : '') + '` regenerates this file.',
244
- '',
245
- '## Available agents',
270
+ '# Personal kit',
271
+ `> Auto-gen. Edit \`kit/\`; rerun \`kit sync ${target.label ? '<target>' : ''}\`.`,
246
272
  '',
273
+ '## Agents',
247
274
  ];
248
275
  for (const a of kit.agents) {
249
- lines.push(`- **${a.name}** — ${a.description || '(no description)'}`);
276
+ lines.push(`- **${a.name}** — ${summarize(a.description) || '(no description)'}`);
250
277
  }
251
- lines.push('', '## Available commands', '');
278
+ lines.push('', '## Commands');
252
279
  for (const c of kit.commands) {
253
- lines.push(`- **/${c.name}** — ${c.description || '(no description)'}`);
280
+ lines.push(`- **/${c.name}** — ${summarize(c.description) || '(no description)'}`);
254
281
  }
255
- lines.push('', '## Available skills', '');
282
+ lines.push('', '## Skills');
256
283
  for (const s of [...kit.skills, ...kit.skillsExtras]) {
257
- lines.push(`- **${s.name}** — ${s.description || '(no description)'}`);
284
+ lines.push(`- **${s.name}** — ${summarize(s.description) || '(no description)'}`);
258
285
  }
259
286
  lines.push('');
260
287
  return lines.join('\n');
@@ -97,7 +97,26 @@ export function releaseLock(projectRoot) {
97
97
  // { stale: false, reason: 'healthz_ok' } — process exists AND healthz responded
98
98
  // { stale: true, reason: 'pid_gone' } — pid is ESRCH
99
99
  // { stale: true, reason: 'healthz_failed' } — pid alive but no healthz response (used when healthzProbe provided)
100
- export async function probeStale(lock, { healthzProbe } = {}) {
100
+ // PERF-04: budget for healthz probe inside acquireLockOrReclaim. A misbehaving
101
+ // sidecar that accepts the connection but never responds shouldn't block startup
102
+ // of a fresh sidecar — we treat slow-as-dead and reclaim.
103
+ const HEALTHZ_PROBE_TIMEOUT_MS = 500;
104
+
105
+ function withTimeout(promise, ms, fallback) {
106
+ return new Promise((resolve) => {
107
+ let settled = false;
108
+ const timer = setTimeout(() => {
109
+ if (!settled) { settled = true; resolve(fallback); }
110
+ }, ms);
111
+ if (typeof timer.unref === 'function') timer.unref();
112
+ Promise.resolve(promise).then(
113
+ (v) => { if (!settled) { settled = true; clearTimeout(timer); resolve(v); } },
114
+ () => { if (!settled) { settled = true; clearTimeout(timer); resolve(fallback); } },
115
+ );
116
+ });
117
+ }
118
+
119
+ export async function probeStale(lock, { healthzProbe, probeTimeoutMs } = {}) {
101
120
  if (!lock || typeof lock.pid !== 'number') {
102
121
  return { stale: true, reason: 'invalid_lock' };
103
122
  }
@@ -115,26 +134,47 @@ export async function probeStale(lock, { healthzProbe } = {}) {
115
134
  if (!healthzProbe) {
116
135
  return { stale: false, reason: 'pid_alive' };
117
136
  }
118
- try {
119
- const ok = await healthzProbe(lock.port);
120
- if (ok) return { stale: false, reason: 'healthz_ok' };
121
- return { stale: true, reason: 'healthz_failed' };
122
- } catch {
123
- return { stale: true, reason: 'healthz_failed' };
124
- }
137
+ // PERF-04: bound the probe so a hung sidecar can't stall startup forever.
138
+ const ms = probeTimeoutMs ?? HEALTHZ_PROBE_TIMEOUT_MS;
139
+ const ok = await withTimeout(healthzProbe(lock.port), ms, false);
140
+ if (ok) return { stale: false, reason: 'healthz_ok' };
141
+ return { stale: true, reason: 'healthz_failed' };
125
142
  }
126
143
 
127
144
  // Convenience: take + retry once if stale lock is detected.
145
+ // SEC-01: re-prove staleness after releaseLock and before the retry acquire to
146
+ // close the TOCTOU window where a competing process could have raced into the
147
+ // lockfile between our probe and our retry.
128
148
  export async function acquireLockOrReclaim(opts) {
129
149
  try {
130
150
  return acquireLock(opts);
131
151
  } catch (err) {
132
152
  if (err.code !== 'ELOCKED') throw err;
133
153
  const existing = readLock(opts.projectRoot);
134
- const probe = await probeStale(existing, { healthzProbe: opts.healthzProbe });
154
+ const probe = await probeStale(existing, { healthzProbe: opts.healthzProbe, probeTimeoutMs: opts.probeTimeoutMs });
135
155
  if (probe.stale) {
136
156
  releaseLock(opts.projectRoot);
137
- return acquireLock(opts);
157
+ // SEC-01: second prove. If something raced into the lock between our
158
+ // releaseLock and our retry-acquire, surface ELIVE instead of clobbering.
159
+ try {
160
+ return acquireLock(opts);
161
+ } catch (retryErr) {
162
+ if (retryErr.code !== 'ELOCKED') throw retryErr;
163
+ const racer = readLock(opts.projectRoot);
164
+ const racerProbe = await probeStale(racer, { healthzProbe: opts.healthzProbe, probeTimeoutMs: opts.probeTimeoutMs });
165
+ if (racerProbe.stale) {
166
+ // Genuinely dead again — third try. If THIS fails too, give up.
167
+ releaseLock(opts.projectRoot);
168
+ return acquireLock(opts);
169
+ }
170
+ const liveErr = new Error(
171
+ `Sidecar reclaimed by another process during retry (pid=${racer?.pid}, port=${racer?.port}). ` +
172
+ `Use \`kit ui status\` to inspect.`,
173
+ );
174
+ liveErr.code = 'ELIVE';
175
+ liveErr.lock = racer;
176
+ throw liveErr;
177
+ }
138
178
  }
139
179
  const liveErr = new Error(
140
180
  `Sidecar already running for this project (pid=${existing?.pid}, port=${existing?.port}). ` +
package/src/ui/server.js CHANGED
@@ -311,12 +311,30 @@ export function createServer({
311
311
  });
312
312
  }
313
313
 
314
- function handleState(res) {
314
+ // PERF-05: optional pagination via ?offset=N&limit=M. No query → ring inteiro
315
+ // (back-compat preservada). Out-of-range values clamp to bounds rather than 4xx.
316
+ function handleState(res, url) {
317
+ let events = ring;
318
+ const offsetRaw = url?.searchParams?.get('offset');
319
+ const limitRaw = url?.searchParams?.get('limit');
320
+ if (offsetRaw !== null && offsetRaw !== undefined) {
321
+ const offset = Math.max(0, Number.parseInt(offsetRaw, 10) || 0);
322
+ const limit = limitRaw !== null && limitRaw !== undefined
323
+ ? Math.max(0, Number.parseInt(limitRaw, 10) || 0)
324
+ : ring.length - offset;
325
+ events = ring.slice(offset, offset + limit);
326
+ } else if (limitRaw !== null && limitRaw !== undefined) {
327
+ const limit = Math.max(0, Number.parseInt(limitRaw, 10) || 0);
328
+ events = ring.slice(0, limit);
329
+ } else {
330
+ events = ring.slice();
331
+ }
315
332
  sendJson(res, 200, {
316
333
  version,
317
334
  port: listeningPort,
318
335
  eventsTotal: nextSeq - 1,
319
- events: ring.slice(),
336
+ ringSize: ring.length,
337
+ events,
320
338
  });
321
339
  }
322
340
 
@@ -360,7 +378,7 @@ export function createServer({
360
378
  case 'GET /healthz':
361
379
  return handleHealthz(res);
362
380
  case 'GET /state':
363
- return handleState(res);
381
+ return handleState(res, url);
364
382
  case 'POST /publish':
365
383
  return handlePublish(req, res);
366
384
  case 'POST /shutdown':
@@ -613,6 +613,23 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
613
613
  flex-shrink: 0;
614
614
  }
615
615
 
616
+ /* Source pill: identifies which terminal/IDE/PID emitted this event.
617
+ Used by the sidecar-tool-publisher hook (v1.6+) and any future multi-source clients. */
618
+ .tl-source {
619
+ font-family: var(--mono);
620
+ font-size: 10px;
621
+ color: var(--text-3);
622
+ background: var(--surface-2);
623
+ border: 1px solid var(--line);
624
+ border-radius: 3px;
625
+ padding: 1px 6px;
626
+ flex-shrink: 0;
627
+ }
628
+ .tl-source[data-ide="claude-code"] { color: var(--accent); border-color: var(--accent-soft); }
629
+ .tl-source[data-ide="cursor"] { color: #8be9fd; }
630
+ .tl-source[data-ide="vscode"] { color: #58a6ff; }
631
+ .tl-source[data-ide="jetbrains"] { color: #ff9d76; }
632
+
616
633
  /* ───────────────────────── empty state ───────────────────────── */
617
634
  .empty {
618
635
  margin: 32px 0;
@@ -1679,7 +1696,8 @@ function rowHtml(evt, idx, prev) {
1679
1696
  case "tool_invocation": {
1680
1697
  const tool = safeStr(evt.payload?.tool || "");
1681
1698
  const title = humanizeTool(tool) || fallback(evt.payload?.title, evt.payload?.label, "ferramenta invocada");
1682
- msg = `<strong>${escapeHtml(title)}</strong>${tool ? ` <span class="ident">${escapeHtml(tool)}</span>` : ""}`;
1699
+ const argsHint = renderArgsSummary(evt.payload?.argsSummary);
1700
+ msg = `<strong>${escapeHtml(title)}</strong>${tool ? ` <span class="ident">${escapeHtml(tool)}</span>` : ""}${argsHint}`;
1683
1701
  break;
1684
1702
  }
1685
1703
  case "shutdown": {
@@ -1689,6 +1707,7 @@ function rowHtml(evt, idx, prev) {
1689
1707
  default:
1690
1708
  msg = `<span class="ident">${escapeHtml(badge.toLowerCase())}</span>`;
1691
1709
  }
1710
+ const sourcePill = renderSourcePill(evt.payload?.source);
1692
1711
  return `
1693
1712
  <div class="tl-row" data-type="${evt.type}" data-ok="${ok}" data-grouped="${grouped}">
1694
1713
  <div class="tl-time" title="${time}">${rel}</div>
@@ -1697,12 +1716,33 @@ function rowHtml(evt, idx, prev) {
1697
1716
  <span class="tl-badge">${badge}</span>
1698
1717
  <span class="tl-msg">${msg}</span>
1699
1718
  ${tokenChip}
1719
+ ${sourcePill}
1700
1720
  ${evt.runId ? `<span class="tl-runid">${evt.runId.slice(0,6)}</span>` : ""}
1701
1721
  </div>
1702
1722
  </div>
1703
1723
  `;
1704
1724
  }
1705
1725
 
1726
+ function renderArgsSummary(args) {
1727
+ if (!args || typeof args !== "object") return "";
1728
+ const bits = [];
1729
+ if (args.file_path) bits.push(`<span class="path">${escapeHtml(safeStr(args.file_path))}</span>`);
1730
+ if (args.command) bits.push(`<span class="ident">${escapeHtml(safeStr(args.command))}</span>`);
1731
+ if (args.pattern) bits.push(`<span class="ident">/${escapeHtml(safeStr(args.pattern))}/</span>`);
1732
+ if (args.url) bits.push(`<span class="ident">${escapeHtml(safeStr(args.url))}</span>`);
1733
+ if (args.description) bits.push(`<span class="ident">${escapeHtml(safeStr(args.description))}</span>`);
1734
+ if (args.action_count) bits.push(`<span class="ident">×${args.action_count}</span>`);
1735
+ return bits.length ? ` ${bits.join(" ")}` : "";
1736
+ }
1737
+
1738
+ function renderSourcePill(source) {
1739
+ if (!source || typeof source !== "object") return "";
1740
+ const ide = safeStr(source.ide || "");
1741
+ const id = safeStr(source.id || "") || (source.pid ? `${ide}:${source.pid}` : ide);
1742
+ if (!id) return "";
1743
+ return `<span class="tl-source" data-ide="${escapeHtml(ide)}" title="${escapeHtml(id)}">${escapeHtml(id)}</span>`;
1744
+ }
1745
+
1706
1746
  function escapeHtml(s) {
1707
1747
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
1708
1748
  }
package/src/ui/wrapper.js CHANGED
@@ -21,16 +21,26 @@ function escapeForReplace(s) {
21
21
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
22
  }
23
23
 
24
+ // SEC-03: Match Windows-style paths (backslash) AND POSIX-style (forward slash)
25
+ // AND case variants on case-insensitive filesystems. We swap each separator for
26
+ // a placeholder, then regex-escape the rest, then put back a char class that
27
+ // matches either separator. 'i' flag handles case-insensitive Windows drives.
28
+ const PATH_SEP_PLACEHOLDER = 'KMSEP';
29
+ function buildPathRegex(rawPath) {
30
+ const withPlaceholders = rawPath.replace(/[\\/]+/g, PATH_SEP_PLACEHOLDER);
31
+ const escaped = escapeForReplace(withPlaceholders);
32
+ const flexible = escaped.split(PATH_SEP_PLACEHOLDER).join('[\\\\/]+');
33
+ return new RegExp(flexible, 'gi');
34
+ }
35
+
24
36
  export function redactPath(value, projectRoot) {
25
37
  if (typeof value === 'string') {
26
38
  let out = value;
27
39
  if (projectRoot) {
28
- const re = new RegExp(escapeForReplace(projectRoot), 'g');
29
- out = out.replace(re, '<project>');
40
+ out = out.replace(buildPathRegex(projectRoot), '<project>');
30
41
  }
31
42
  if (HOME) {
32
- const re = new RegExp(escapeForReplace(HOME), 'g');
33
- out = out.replace(re, '~');
43
+ out = out.replace(buildPathRegex(HOME), '~');
34
44
  }
35
45
  return out;
36
46
  }