@luanpdd/kit-mcp 1.5.2 → 1.5.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/CHANGELOG.md +55 -0
- package/kit/agents/planner.md +113 -638
- package/kit/hooks/sidecar-tool-publisher.js +182 -0
- package/package.json +3 -2
- package/src/cli/index.js +1 -1
- package/src/core/kit.js +25 -4
- package/src/core/reverse-sync.js +2 -1
- package/src/core/sync.js +40 -13
- package/src/mcp-server/index.js +3 -1
- package/src/ui/lockfile.js +50 -10
- package/src/ui/server.js +27 -5
- package/src/ui/static/index.html +41 -1
- package/src/ui/wrapper.js +14 -4
|
@@ -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
|
+
"version": "1.5.4",
|
|
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/cli/index.js
CHANGED
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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];
|
package/src/core/reverse-sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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('', '##
|
|
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('', '##
|
|
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');
|
package/src/mcp-server/index.js
CHANGED
|
@@ -253,7 +253,9 @@ const HANDLERS = {
|
|
|
253
253
|
};
|
|
254
254
|
|
|
255
255
|
function slim(x) {
|
|
256
|
-
|
|
256
|
+
// absPath omitted by design — list-* tools are AI-consumed in tight context budgets.
|
|
257
|
+
// Use action=get to fetch the absPath (and content) for a specific item.
|
|
258
|
+
return { kind: x.kind, name: x.name, description: x.description };
|
|
257
259
|
}
|
|
258
260
|
|
|
259
261
|
// --- server bootstrap ---
|
package/src/ui/lockfile.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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,16 +311,38 @@ export function createServer({
|
|
|
311
311
|
});
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
-
|
|
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
|
-
|
|
336
|
+
ringSize: ring.length,
|
|
337
|
+
events,
|
|
320
338
|
});
|
|
321
339
|
}
|
|
322
340
|
|
|
323
|
-
async function handleShutdownRequest(res) {
|
|
341
|
+
async function handleShutdownRequest(req, res) {
|
|
342
|
+
if (!isOriginAllowed(req, listeningPort)) {
|
|
343
|
+
sendJson(res, 403, { error: 'origin_not_allowed' });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
324
346
|
sendJson(res, 200, { ok: true, draining: true });
|
|
325
347
|
setImmediate(() => {
|
|
326
348
|
shutdown('explicit').catch((err) => logErr('shutdown error:', err.message));
|
|
@@ -356,11 +378,11 @@ export function createServer({
|
|
|
356
378
|
case 'GET /healthz':
|
|
357
379
|
return handleHealthz(res);
|
|
358
380
|
case 'GET /state':
|
|
359
|
-
return handleState(res);
|
|
381
|
+
return handleState(res, url);
|
|
360
382
|
case 'POST /publish':
|
|
361
383
|
return handlePublish(req, res);
|
|
362
384
|
case 'POST /shutdown':
|
|
363
|
-
return handleShutdownRequest(res);
|
|
385
|
+
return handleShutdownRequest(req, res);
|
|
364
386
|
default:
|
|
365
387
|
return sendJson(res, 404, { error: 'not_found', route });
|
|
366
388
|
}
|
package/src/ui/static/index.html
CHANGED
|
@@ -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
|
-
|
|
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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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
|
-
|
|
29
|
-
out = out.replace(re, '<project>');
|
|
40
|
+
out = out.replace(buildPathRegex(projectRoot), '<project>');
|
|
30
41
|
}
|
|
31
42
|
if (HOME) {
|
|
32
|
-
|
|
33
|
-
out = out.replace(re, '~');
|
|
43
|
+
out = out.replace(buildPathRegex(HOME), '~');
|
|
34
44
|
}
|
|
35
45
|
return out;
|
|
36
46
|
}
|