Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,11 @@ broken on some Windows 11 builds (stuck in `playState 9`).

The local STT provider uses whisper.cpp via the `nodejs-whisper` package:
- **Windows:** Prebuilt `whisper-cli` binary is auto-downloaded from GitHub releases (v1.8.3).
- **macOS/Linux:** Built from source via CMake (requires build tools).
- **macOS/Linux:** Built from source via CMake. On macOS, Xcode Command Line
Tools alone are not enough — CMake must be installed separately
(`brew install cmake`).
- **All platforms:** Requires FFmpeg on PATH for audio conversion.
- **Bundling:** `nodejs-whisper` must stay in the esbuild `external` list in
`scripts/build-mcp.mjs` (and in root `package.json` dependencies so the
bundle can resolve it). Inlining it breaks `__dirname` resolution and the
path to its bundled whisper.cpp tree.
93 changes: 78 additions & 15 deletions bin/devglide.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,27 +280,70 @@ function runMcpServer(name) {

// --- Unified server ---

function startServer(foreground = false) {
/** Probe the health endpoint. Returns the health JSON, or null if nothing answers. */
async function probeHealth() {
try {
const res = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/health`, {
signal: AbortSignal.timeout(1500),
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}

/** Print the last lines of the server log to help diagnose a failed start. */
function printLogTail(lines = 15) {
try {
const logFile = resolve(logDir, "server.log");
const content = readFileSync(logFile, "utf8").trimEnd().split("\n");
const tail = content.slice(-lines);
if (tail.length) {
console.error(`\n Last ${tail.length} log lines (${logFile}):`);
for (const line of tail) console.error(` ${line}`);
}
} catch { /* no log yet */ }
}

async function startServer(foreground = false) {
const { running, pid } = getStatus("server");
if (running) {
console.log(` server already running (pid ${pid})`);
return;
}

const tsxBin = resolve(root, "node_modules/.bin/tsx");
if (!existsSync(tsxBin)) {
// Preflight: the pid file may be stale while a *different* process still
// holds the port (e.g. a server started from another working copy).
// Starting on top of it would exit with EADDRINUSE after we already
// reported success — detect and refuse instead.
const existing = await probeHealth();
if (existing) {
console.error(
` port :${SERVER_PORT} is already served by another devglide process` +
(existing.pid ? ` (pid ${existing.pid})` : "") +
` not tracked by this CLI.`
);
console.error(` Stop it first (devglide stop, or kill the pid above), then retry.`);
process.exitCode = 1;
return;
}

// Spawn tsx via its JS entry with the current node binary — no shell.
// shell:true combined with detached:true silently discards fd-redirected
// stdio on Windows (cmd.exe attaches to a fresh console), which made the
// daemon log permanently empty and startup failures undiagnosable.
const tsxCli = resolve(root, "node_modules/tsx/dist/cli.mjs");
if (!existsSync(tsxCli)) {
console.error("tsx not found. Run 'pnpm install' first.");
process.exit(1);
}

if (foreground) {
// Quote tsxBin so a path with spaces survives shell:true (needed on Windows
// to resolve the extensionless `.bin/tsx` launcher).
const child = spawn(`"${tsxBin}"`, ["src/server.ts"], {
const child = spawn(process.execPath, [tsxCli, "src/server.ts"], {
cwd: root,
stdio: "inherit",
env: { ...process.env, PORT: String(SERVER_PORT) },
shell: true,
});
child.on("exit", (code) => process.exit(code ?? 1));
return;
Expand All @@ -309,18 +352,38 @@ function startServer(foreground = false) {
// Background daemon
const logFile = resolve(logDir, "server.log");
const out = openSync(logFile, "a");
// Quote tsxBin so a path with spaces survives shell:true.
const child = spawn(`"${tsxBin}"`, ["src/server.ts"], {
const child = spawn(process.execPath, [tsxCli, "src/server.ts"], {
cwd: root,
stdio: ["ignore", out, out],
env: { ...process.env, PORT: String(SERVER_PORT) },
detached: true,
shell: true,
});
savePid("server", child.pid);
child.unref();
closeSync(out);
console.log(` server started on :${SERVER_PORT} (pid ${child.pid})`);

// Verify the daemon actually came up before claiming success: the child
// must stay alive AND the health endpoint must answer.
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
if (!isRunning(child.pid)) {
removePid("server");
console.error(` server failed to start — process exited during startup.`);
printLogTail();
process.exitCode = 1;
return;
}
if (await probeHealth()) {
console.log(` server started on :${SERVER_PORT} (pid ${child.pid})`);
return;
}
await new Promise((r) => setTimeout(r, 250));
}

removePid("server");
console.error(` server did not become ready on :${SERVER_PORT} within 15s.`);
printLogTail();
process.exitCode = 1;
}

function killTree(pid, signal) {
Expand Down Expand Up @@ -676,15 +739,15 @@ ${Object.keys(mcpServers).map((name) => ` ${name}`).join("\n")}

switch (command) {
case "dev":
startServer(true);
startServer(true).catch((err) => { console.error(err); process.exitCode = 1; });
break;

case "start":
case "server":
if (args[0] === "stop") {
stopServer();
} else {
startServer(command === "dev");
startServer(command === "dev").catch((err) => { console.error(err); process.exitCode = 1; });
}
break;

Expand All @@ -697,8 +760,8 @@ switch (command) {
// starting the new one, otherwise startServer can hit EADDRINUSE.
(async () => {
await stopServer();
startServer(false);
})();
await startServer(false);
})().catch((err) => { console.error(err); process.exitCode = 1; });
break;

case "status":
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"express": "^5.2.1",
"multer": "1.4.5-lts.2",
"node-pty": "^1.1.0",
"nodejs-whisper": "^0.2.9",
"openai": "^4.104.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion scripts/build-mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ const servers = [
// Native / optional packages must not be inlined by esbuild. nodejs-whisper pulls in
// whisper.cpp and is an optional STT dependency that may be absent or fail to install;
// the voice provider imports it lazily and degrades gracefully at runtime. Bundling it
// makes the build fail with "Could not resolve 'nodejs-whisper'" when it is not present.
// makes the build fail with "Could not resolve 'nodejs-whisper'" when it is not present —
// and even when present, its CJS code references __dirname (undefined in ESM bundle
// scope) and resolves its whisper.cpp tree relative to its own install location.
const external = ["better-sqlite3", "node-pty", "nodejs-whisper"];

// CJS packages bundled into ESM need a real require() for Node built-ins
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ describe('parseTargetTokens', () => {
expect(registry.parseTargetTokens('@claude-7 and @claude-7 again')).toEqual(['claude-7']);
});

it('ignores mid-word @ (emails must not become mentions)', () => {
expect(registry.parseTargetTokens('ping admin@example.com about the outage')).toEqual([]);
expect(registry.parseTargetTokens('contact a@b and c@d please')).toEqual([]);
});

it('still extracts a mention at start or after brackets', () => {
expect(registry.parseTargetTokens('(@claude-7) take this')).toEqual(['claude-7']);
});

it('merges explicit to param for user senders', () => {
expect(registry.parseTargetTokens('@codex-14 review', 'claude-7', 'user')).toEqual(['claude-7', 'codex-14']);
});
Expand Down
35 changes: 29 additions & 6 deletions src/apps/chat/services/chat-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,10 +756,13 @@ export async function send(from: string, body: string, to?: string, projectId?:
if (plan.recipients.length > 0) {
expectedDeliveryCount = plan.recipients.length;
} else if (plan.fallbackBroadcast) {
// Count broadcast targets (Option B fallback)
// Count broadcast targets (Option B fallback). Exclude detached
// participants — deliverToPty skips them, and counting them reports
// deliveries that never happened (e.g. restored-but-unreclaimed agents
// after a server restart).
expectedDeliveryCount = 0;
for (const p of participants.values()) {
if (p.name !== from && p.paneId && p.projectId === resolvedSenderProjectId) {
if (p.name !== from && p.paneId && !p.detached && p.projectId === resolvedSenderProjectId) {
expectedDeliveryCount++;
}
}
Expand Down Expand Up @@ -790,8 +793,9 @@ export async function send(from: string, body: string, to?: string, projectId?:
}
} else if (plan.fallbackBroadcast) {
// Option B: unaddressed user/system messages still broadcast
// (skip detached participants — matches the count above and deliverToPty)
for (const p of participants.values()) {
if (p.name !== from && p.paneId && p.projectId === resolvedSenderProjectId) {
if (p.name !== from && p.paneId && !p.detached && p.projectId === resolvedSenderProjectId) {
await deliverToPty(p.name, resolvedSenderProjectId, msg);
}
}
Expand Down Expand Up @@ -860,8 +864,13 @@ export function parseTargetTokens(body: string, to?: string, senderKind?: 'user'
// in DevGlide use `-` as the separator. This is the parser-side
// defense; `buildDeliveryPlan` adds a second defense by filtering
// tokens that don't resolve to a real participant.
// The @ must additionally NOT be preceded by an alphanumeric/./@/- —
// a mid-word @ is an email or host token ("admin@example.com", "a@b"),
// and treating it as a mention both mistargets delivery and suppresses
// the unaddressed-broadcast fallback. Markdown decoration (* _ ~) and
// brackets still count as valid boundaries.
const scannable = stripCodeRegions(body);
const regex = /@([a-zA-Z0-9-]+)/g;
const regex = /(?:^|[^A-Za-z0-9.@-])@([a-zA-Z0-9-]+)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(scannable)) !== null) {
const token = match[1];
Expand Down Expand Up @@ -987,7 +996,18 @@ function deliverToPty(targetName: string, projectId: string | null, msg: ChatMes
}

const header = formatPtyHeader(targetName, msg);
let formatted = `${header} @${msg.from}: ${msg.body}`;
// Sanitize the body before it reaches another agent's terminal:
// - \r would submit attacker-chosen partial input immediately on
// line-based CLIs, defeating the deliberate delayed-submit design
// - ANSI escapes pass through to the recipient's TUI
// - a line matching the injected-header pattern would forge the
// server-controlled authority header ("Assigned by: pipe")
const sanitizedBody = stripAnsi(msg.body)
.replace(/\r\n?/g, '\n')
.split('\n')
.map((line) => (isChatInjectedOutput(line) ? `> ${line}` : line))
.join('\n');
let formatted = `${header} @${msg.from}: ${sanitizedBody}`;

// Write with retry — if the initial write fails, retry once after a short delay
let writeOk = false;
Expand Down Expand Up @@ -1097,7 +1117,10 @@ export function getParticipantByPaneId(paneId: string, projectId?: string | null
/** Clear chat history for the active project and notify dashboard clients. */
export function clearHistory(projectId?: string | null): void {
const pid = resolveProjectId(projectId);
clearMessages(pid);
// Preserve the persistence files of pipes that are still running — their
// event logs are the crash-recovery source of truth.
const running = new Set(pipeStore.listActivePipes(pid).map((p) => p.pipeId));
clearMessages(pid, running);
emitToProject('chat:cleared', {}, pid);
}

Expand Down
18 changes: 13 additions & 5 deletions src/apps/chat/services/chat-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ function getMessagesPath(projectId?: string | null): string | null {
}

function getPipeMessagesPath(pipeId: string, projectId?: string | null): string | null {
// Same traversal guard as getPipeEventsPath — the id is joined into a path.
if (!/^[\w-]+$/.test(pipeId)) return null;
const dir = getChatDir(projectId);
if (!dir) return null;
const pipesDir = join(dir, 'pipes');
Expand All @@ -39,6 +41,9 @@ function getPipeEventsLogPath(projectId?: string | null): string | null {
}

function getPipeEventsPath(pipeId: string, projectId?: string | null): string | null {
// The id is joined into a filesystem path — allow only word chars and
// dashes (no separators, no dots) so it cannot traverse out of pipes/.
if (!/^[\w-]+$/.test(pipeId)) return null;
const dir = getChatDir(projectId);
if (!dir) return null;
const pipesDir = join(dir, 'pipes');
Expand Down Expand Up @@ -228,7 +233,7 @@ export function loadParticipants(projectId?: string | null): PersistedParticipan
}


export function clearMessages(projectId?: string | null): void {
export function clearMessages(projectId?: string | null, keepPipeIds?: ReadonlySet<string>): void {
const filePath = getMessagesPath(projectId);
if (filePath && existsSync(filePath)) {
writeFileSync(filePath, '');
Expand All @@ -237,15 +242,18 @@ export function clearMessages(projectId?: string | null): void {
if (pipeEventsPath && existsSync(pipeEventsPath)) {
unlinkSync(pipeEventsPath);
}
// Also clear per-pipe JSONL files
// Also clear per-pipe JSONL files — EXCEPT those of pipes the caller marks
// as still running: their .events.jsonl is the crash-recovery source, and
// deleting it mid-run silently drops the pipe on the next restart.
const dir = getChatDir(projectId);
if (dir) {
const pipesDir = join(dir, 'pipes');
if (existsSync(pipesDir)) {
for (const file of readdirSync(pipesDir)) {
if (file.endsWith('.jsonl')) {
unlinkSync(join(pipesDir, file));
}
if (!file.endsWith('.jsonl')) continue;
const pipeId = file.replace(/\.events\.jsonl$|\.jsonl$/, '');
if (keepPipeIds?.has(pipeId)) continue;
unlinkSync(join(pipesDir, file));
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/apps/chat/services/pipe-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,21 @@ describe("pipe-store lease expiry enforcement", () => {
expect(pipeStore.isLeaseExpired(result.lease!)).toBe(false);
});

it("isLeaseExpired returns true when past deadline", () => {
// Pin the timeout explicitly — the check must not depend on DEFAULT_STAGE_TIMEOUT_MS.
pipeStore.createPipe(
"pipe-1",
"linear",
["alice", "bob", "carol"],
"test prompt",
"proj-1",
{ stageTimeoutMs: 60_000 },
);
const result = pipeStore.grantLease("pipe-1", "alice", "proj-1");
const pastDeadline = Date.now() + 2 * 60_000;
expect(pipeStore.isLeaseExpired(result.lease!, pastDeadline)).toBe(true);
});

it("submitStage accepts submission with active lease", () => {
createLinearPipe();
pipeStore.grantLease("pipe-1", "alice", "proj-1");
Expand Down
22 changes: 21 additions & 1 deletion src/apps/chat/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ export function registerChatMcpHttpSession(sessionId: string, server: McpServer)
getServerState(server);
}

/**
* True when a DIFFERENT live MCP session currently tracks the same
* participant. Used by onSessionClose: a stale session closing (TTL reap,
* late clean close) must not detach a participant that has since been
* reclaimed under a new session — that would fail its running pipes and
* eject a live agent.
*/
export function isNameTrackedByAnotherSession(server: McpServer, name: string, projectId: string | null): boolean {
for (const other of chatMcpServersBySessionId.values()) {
if (other === server) continue;
const entry = chatMcpServerStates.get(other)?.sessionEntry;
if (entry && entry.name === name && entry.projectId === projectId) return true;
}
return false;
}

export function unregisterChatMcpHttpSession(server: McpServer, sessionId?: string): void {
if (sessionId) {
if (chatMcpServersBySessionId.get(sessionId) === server) chatMcpServersBySessionId.delete(sessionId);
Expand Down Expand Up @@ -479,7 +495,11 @@ export function createChatMcpServer(): McpServer {
async () => {
// Use REST API for consistent behavior — direct registry calls can miss
// participants when sessionProjectId is null (before join or after restart).
const res = await chatApi('/members');
// Forward the session's project like every other tool — the router
// otherwise falls back to the dashboard's ACTIVE project, which may not
// be the project this agent joined from.
const pid = getSessionProjectId();
const res = await chatApi(pid ? `/members?projectId=${encodeURIComponent(pid)}` : '/members');
if (!res.ok) return errorResult('Failed to fetch members');
return jsonResult(res.data);
},
Expand Down
Loading
Loading