Skip to content

Commit 267b396

Browse files
Fix Windows command execution, stale-daemon upgrades, and chat layout
- Windows: the agent's bash/glob tools spawned /bin/sh, which doesn't exist on Windows (ENOENT → no commands ran, no edits made). Add a cross-platform shellInvocation (cmd.exe on Windows), give glob a Node-based fallback, and tell the agent its OS in the system prompt. - Stale daemon: the detached daemon survives reinstalls, so users got stuck on an old cloned version. ensureDaemon (CLI and app) now compares the live daemon's version to the installed one and restarts it on mismatch. - Chat UI: single scroll container so resizing no longer duplicates the composer; add spacing between the conversation and the composer. - Bump coderouter-cli 0.1.3 and Studio 0.1.2. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8ae03c7 commit 267b396

11 files changed

Lines changed: 199 additions & 28 deletions

File tree

packages/app/electron/main.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,48 @@ async function ping(port: number): Promise<DaemonInfo | null> {
4444
}
4545
}
4646

47+
/**
48+
* The daemon version this app build expects, read from the bundled (or, in
49+
* dev, the sibling workspace) CLI package.json. Used to detect a stale daemon
50+
* left running by an older install so we can replace it.
51+
*/
52+
function expectedDaemonVersion(): string | null {
53+
const candidates = [
54+
join(process.resourcesPath, 'cli', 'package.json'),
55+
join(app.getAppPath(), '..', 'cli', 'package.json'),
56+
];
57+
for (const p of candidates) {
58+
try {
59+
const v = (JSON.parse(readFileSync(p, 'utf8')) as { version?: string }).version;
60+
if (typeof v === 'string' && v) return v;
61+
} catch {
62+
// try next
63+
}
64+
}
65+
return null;
66+
}
67+
68+
/** Stop a running daemon by PID and wait for its port to free. */
69+
async function stopDaemon(info: DaemonInfo): Promise<void> {
70+
try {
71+
process.kill(info.pid, 'SIGTERM');
72+
} catch {
73+
// already gone
74+
}
75+
const deadline = Date.now() + 5000;
76+
while (Date.now() < deadline) {
77+
if (!(await ping(info.port))) break;
78+
await new Promise((r) => setTimeout(r, 200));
79+
}
80+
if (await ping(info.port)) {
81+
try {
82+
process.kill(info.pid, 'SIGKILL');
83+
} catch {
84+
// ignore
85+
}
86+
}
87+
}
88+
4789
/**
4890
* Locate a standalone Node binary. The daemon must run under regular Node
4991
* (not Electron's bundled Node) so native addons like `node-pty` — which
@@ -110,7 +152,13 @@ async function ensureDaemon(): Promise<DaemonInfo> {
110152
const existing = readDaemonInfo();
111153
if (existing) {
112154
const alive = await ping(existing.port);
113-
if (alive) return alive;
155+
if (alive) {
156+
// Replace a stale daemon from an older install so the app always runs
157+
// the version it shipped with.
158+
const want = expectedDaemonVersion();
159+
if (!want || alive.version === want) return alive;
160+
await stopDaemon(alive);
161+
}
114162
}
115163

116164
const { cmd, args, electronNode } = resolveCli();

packages/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@coderouter/app",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"private": true,
55
"description": "CodeRouter Studio — Electron desktop app (Loops, Projects, Chats, Usage)",
66
"author": "Efe Acar",

packages/app/scripts/stage-daemon.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ writeFileSync(
5555
name: 'coderouter-daemon',
5656
private: true,
5757
type: 'module',
58+
version: cliPkg.version,
5859
dependencies: {
5960
'@vscode/ripgrep': want('@vscode/ripgrep'),
6061
ink: want('ink'),

packages/app/src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,9 +331,9 @@ function Shell(): React.ReactElement {
331331
</div>
332332
</header>
333333
<div className="flex min-h-0 flex-1">
334-
<div className="min-h-0 flex-1 overflow-y-auto">
334+
<div className={cls('min-h-0 flex-1', nav === 'chat' ? 'overflow-hidden' : 'overflow-y-auto')}>
335335
{nav === 'chat' ? (
336-
<div className="h-full px-12 py-6">
336+
<div className="h-full px-12 pt-6 pb-4">
337337
<ChatPage
338338
chatId={chatId}
339339
project={project}

packages/app/src/pages/Chat.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,14 +215,14 @@ export function ChatPage({
215215

216216
return (
217217
<div className="mx-auto flex h-full max-w-2xl flex-col">
218-
<div ref={scrollRef} className="min-h-0 flex-1 space-y-5 overflow-y-auto py-2">
218+
<div ref={scrollRef} className="min-h-0 flex-1 space-y-6 overflow-y-auto px-1 pb-6 pt-2">
219219
{loadingHistory && <Spinner />}
220220
{messages.map((m, i) => (
221221
<MessageRow key={i} msg={m} />
222222
))}
223223
</div>
224224
{error && <div className="mb-2 rounded-md border border-bad/40 bg-bad/10 px-3 py-2 text-sm text-bad">{error}</div>}
225-
{composer}
225+
<div className="pt-3">{composer}</div>
226226
</div>
227227
);
228228
}

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "coderouter-cli",
3-
"version": "0.1.2",
3+
"version": "0.1.3",
44
"type": "module",
55
"description": "A coding-agent router that picks the best model for each task, runs edits in git-worktree sandboxes, and works with your existing Claude Code / Codex CLI or any API key (OpenAI, Anthropic, OpenRouter, ...).",
66
"license": "MIT",

packages/cli/src/daemon/lockfile.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
22
import { readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
33
import { join } from 'node:path';
44
import { coderouterHome } from '@coderouter/core';
5+
import { CLI_VERSION } from '../version.js';
56

67
/**
78
* Daemon singleton bookkeeping. The daemon writes `~/.coderouter/daemon.json`
@@ -58,16 +59,51 @@ export async function pingDaemon(port: number, timeoutMs = 1500): Promise<Daemon
5859
}
5960
}
6061

62+
/**
63+
* Stop a running daemon by PID and wait for it to release its port. Used to
64+
* replace a stale daemon left running by an older install (the daemon is
65+
* detached and survives a `git pull`/reinstall, so without this a user can be
66+
* stuck on the old version indefinitely).
67+
*/
68+
export async function stopDaemon(info: DaemonInfo, waitMs = 5000): Promise<void> {
69+
try {
70+
process.kill(info.pid, 'SIGTERM');
71+
} catch {
72+
// already gone
73+
}
74+
const deadline = Date.now() + waitMs;
75+
while (Date.now() < deadline) {
76+
if (!(await pingDaemon(info.port, 800))) break;
77+
await new Promise((r) => setTimeout(r, 200));
78+
}
79+
if (await pingDaemon(info.port, 800)) {
80+
try {
81+
process.kill(info.pid, 'SIGKILL');
82+
} catch {
83+
// ignore
84+
}
85+
}
86+
clearDaemonInfo();
87+
}
88+
6189
/**
6290
* Return a live daemon's info, spawning one (detached) if needed. The
6391
* spawned process keeps running after this CLI exits so loops survive.
92+
*
93+
* If a daemon is alive but runs a different version than this CLI (e.g. left
94+
* over from an older clone/install), it is stopped and replaced so users
95+
* always run the version they just installed.
6496
*/
6597
export async function ensureDaemon(opts: { cwd: string } = { cwd: process.cwd() }): Promise<DaemonInfo> {
6698
const existing = readDaemonInfo();
6799
if (existing) {
68100
const alive = await pingDaemon(existing.port);
69-
if (alive) return alive;
70-
clearDaemonInfo();
101+
if (alive) {
102+
if (alive.version === CLI_VERSION) return alive;
103+
await stopDaemon(alive);
104+
} else {
105+
clearDaemonInfo();
106+
}
71107
}
72108

73109
// Spawn `coderouter daemon` detached. argv[1] is this CLI's entry.

packages/core/src/agent/systemPrompt.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ export const DEFAULT_SYSTEM_PROMPT = `You are CodeRouter Agent, a precise coding
3232
* memory.md / project context without forking the whole prompt.
3333
*/
3434
export function buildSystemPrompt(opts: { append?: string } = {}): string {
35-
if (!opts.append?.trim()) return DEFAULT_SYSTEM_PROMPT;
36-
return `${DEFAULT_SYSTEM_PROMPT}\n\n# Project context\n${opts.append.trim()}`;
35+
const base = `${DEFAULT_SYSTEM_PROMPT}\n\n# Environment\n${describeEnvironment()}`;
36+
if (!opts.append?.trim()) return base;
37+
return `${base}\n\n# Project context\n${opts.append.trim()}`;
38+
}
39+
40+
/** A one-liner describing the host OS so the agent generates compatible commands. */
41+
function describeEnvironment(): string {
42+
switch (process.platform) {
43+
case 'win32':
44+
return (
45+
'- OS: Windows. The bash tool runs commands through cmd.exe, so use Windows-compatible ' +
46+
'commands (e.g. chain with `&&`, avoid Unix-only tools like `ls`/`cat`/`grep`). ' +
47+
'Prefer the read_file / glob / grep / list_dir tools over shelling out — they work everywhere.'
48+
);
49+
case 'darwin':
50+
return '- OS: macOS. Shell commands run via /bin/sh.';
51+
default:
52+
return '- OS: Linux. Shell commands run via /bin/sh.';
53+
}
3754
}

packages/core/src/agent/tools/bash.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { exec } from '../../sandbox/exec.js';
1+
import { exec, shellInvocation } from '../../sandbox/exec.js';
22
import type { Tool } from '../types.js';
33
import { MAX_BASH_OUTPUT_BYTES, clip, oneLine, stringArg } from './helpers.js';
44

@@ -12,7 +12,11 @@ export const bashTool: Tool = {
1212
parameters: {
1313
type: 'object',
1414
properties: {
15-
command: { type: 'string', description: 'Shell command to execute via /bin/sh -lc.' },
15+
command: {
16+
type: 'string',
17+
description:
18+
'Shell command to execute. Runs via the system shell (sh on macOS/Linux, cmd.exe on Windows).',
19+
},
1620
timeout_ms: {
1721
type: 'integer',
1822
description: 'Hard timeout in milliseconds. Defaults to 60000.',
@@ -25,7 +29,8 @@ export const bashTool: Tool = {
2529
const command = stringArg(args, 'command');
2630
const timeoutMs =
2731
typeof args.timeout_ms === 'number' && args.timeout_ms > 0 ? args.timeout_ms : 60_000;
28-
const result = await exec('/bin/sh', ['-lc', command], {
32+
const { cmd, args: shArgs } = shellInvocation(command, { login: true });
33+
const result = await exec(cmd, shArgs, {
2934
cwd: ctx.cwd,
3035
signal: ctx.signal,
3136
timeoutMs,

packages/core/src/agent/tools/glob.ts

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { stat } from 'node:fs/promises';
1+
import type { Dirent } from 'node:fs';
2+
import { readdir, stat } from 'node:fs/promises';
3+
import { join, relative, sep } from 'node:path';
24
import { exec } from '../../sandbox/exec.js';
35
import type { Tool, ToolContext } from '../types.js';
4-
import { MAX_GLOB_RESULTS, escapeShellArg, quoted, stringArg } from './helpers.js';
6+
import { MAX_GLOB_RESULTS, quoted, stringArg } from './helpers.js';
57

68
export const globTool: Tool = {
79
name: 'glob',
@@ -41,18 +43,62 @@ async function runGlob(ctx: ToolContext, pattern: string): Promise<string[]> {
4143
);
4244
return stdout.split('\n').filter(Boolean);
4345
}
44-
const { stdout } = await exec(
45-
'/bin/sh',
46-
[
47-
'-lc',
48-
`find . -path ${escapeShellArg(pattern)} -type f -not -path '*/.*' | head -n 1000`,
49-
],
50-
{ cwd: ctx.cwd, signal: ctx.signal },
51-
);
52-
return stdout
53-
.split('\n')
54-
.filter(Boolean)
55-
.map((p) => (p.startsWith('./') ? p.slice(2) : p));
46+
// Non-git worktree: walk the tree in Node (cross-platform; no reliance on a
47+
// POSIX `find`, which doesn't exist on Windows) and match the glob ourselves.
48+
const all = await walkFiles(ctx.cwd);
49+
const rx = globToRegExp(pattern);
50+
return all.filter((p) => rx.test(p)).slice(0, 1000);
51+
}
52+
53+
/** Recursively list files relative to `root`, skipping dotfiles and node_modules. */
54+
async function walkFiles(root: string, max = 5000): Promise<string[]> {
55+
const out: string[] = [];
56+
const rec = async (dir: string): Promise<void> => {
57+
if (out.length >= max) return;
58+
const entries: Dirent[] = await readdir(dir, { withFileTypes: true }).catch(() => [] as Dirent[]);
59+
for (const e of entries) {
60+
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
61+
const full = join(dir, e.name);
62+
if (e.isDirectory()) {
63+
await rec(full);
64+
} else if (e.isFile()) {
65+
out.push(relative(root, full).split(sep).join('/'));
66+
if (out.length >= max) return;
67+
}
68+
}
69+
};
70+
await rec(root);
71+
return out;
72+
}
73+
74+
/** Convert a POSIX-style glob (`**`, `*`, `?`, `{a,b}`) to an anchored RegExp. */
75+
function globToRegExp(glob: string): RegExp {
76+
let re = '';
77+
for (let i = 0; i < glob.length; i++) {
78+
const c = glob.charAt(i);
79+
if (c === '*') {
80+
if (glob.charAt(i + 1) === '*') {
81+
re += '.*';
82+
i++;
83+
if (glob.charAt(i + 1) === '/') i++;
84+
} else {
85+
re += '[^/]*';
86+
}
87+
} else if (c === '?') {
88+
re += '[^/]';
89+
} else if (c === '{') {
90+
re += '(';
91+
} else if (c === '}') {
92+
re += ')';
93+
} else if (c === ',') {
94+
re += '|';
95+
} else if ('.+^$()|[]\\'.includes(c)) {
96+
re += `\\${c}`;
97+
} else {
98+
re += c;
99+
}
100+
}
101+
return new RegExp(`^${re}$`);
56102
}
57103

58104
async function isGitRepo(cwd: string): Promise<boolean> {

0 commit comments

Comments
 (0)