Skip to content

Commit 01a03fc

Browse files
committed
v3.0.0-rc.1: force-kill flag, SSE cap bump, Tower sort fix, handle validation
- af tower stop --force-kill-all-child-processes: kills detached shellper processes and their children (claude, bash), not just the Tower daemon - SSE hard cap bumped from 12 to 50 for multi-workspace usage - Tower overview: running workspaces alphabetical, recent reverse-chronological - GitHub handle validation relaxed to accept trailing hyphens (e.g. timeleft--)
1 parent deb3b7f commit 01a03fc

File tree

7 files changed

+72
-7
lines changed

7 files changed

+72
-7
lines changed

packages/codev/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cluesmith/codev",
3-
"version": "3.0.0-rc.0",
3+
"version": "3.0.0-rc.1",
44
"description": "Codev CLI - AI-assisted software development framework",
55
"type": "module",
66
"bin": {

packages/codev/src/__tests__/team.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,13 @@ describe('isValidGitHubHandle', () => {
9191
expect(isValidGitHubHandle('alice-bob')).toBe(true);
9292
expect(isValidGitHubHandle('Alice123')).toBe(true);
9393
expect(isValidGitHubHandle('a')).toBe(true);
94+
expect(isValidGitHubHandle('alice-')).toBe(true);
95+
expect(isValidGitHubHandle('timeleft--')).toBe(true);
9496
});
9597

9698
it('rejects invalid handles', () => {
9799
expect(isValidGitHubHandle('')).toBe(false);
98100
expect(isValidGitHubHandle('-alice')).toBe(false);
99-
expect(isValidGitHubHandle('alice-')).toBe(false);
100101
expect(isValidGitHubHandle('al ice')).toBe(false);
101102
expect(isValidGitHubHandle('al@ice')).toBe(false);
102103
expect(isValidGitHubHandle('a'.repeat(40))).toBe(false);

packages/codev/src/agent-farm/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,10 +617,12 @@ export async function runAgentFarm(args: string[]): Promise<void> {
617617
.command('stop')
618618
.description('Stop the tower dashboard')
619619
.option('-p, --port <port>', 'Port to stop (default: 4100)')
620+
.option('--force-kill-all-child-processes', 'SIGKILL tower and every child process (builders, shells, everything)')
620621
.action(async (options) => {
621622
try {
622623
await towerStop({
623624
port: options.port ? parseInt(options.port, 10) : undefined,
625+
forceKillAllChildProcesses: options.forceKillAllChildProcesses,
624626
});
625627
} catch (error) {
626628
logger.error(error instanceof Error ? error.message : String(error));

packages/codev/src/agent-farm/commands/tower.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface TowerStartOptions {
2626

2727
export interface TowerStopOptions {
2828
port?: number;
29+
forceKillAllChildProcesses?: boolean;
2930
}
3031

3132
/**
@@ -214,8 +215,9 @@ export async function towerStart(options: TowerStartOptions = {}): Promise<void>
214215
*/
215216
export async function towerStop(options: TowerStopOptions = {}): Promise<void> {
216217
const port = options.port || DEFAULT_TOWER_PORT;
218+
const forceKill = options.forceKillAllChildProcesses || false;
217219

218-
logger.header('Stopping Tower');
220+
logger.header(forceKill ? 'Force-Killing Tower and All Child Processes' : 'Stopping Tower');
219221

220222
const pids = getProcessesOnPort(port);
221223

@@ -224,6 +226,56 @@ export async function towerStop(options: TowerStopOptions = {}): Promise<void> {
224226
return;
225227
}
226228

229+
if (forceKill) {
230+
// Shellper processes are spawned DETACHED from Tower — they intentionally
231+
// survive Tower restarts. So pgrep -P won't find them. We need to:
232+
// 1. Find all shellper-main processes via pgrep -f
233+
// 2. Find all their children (claude, bash, etc.)
234+
// 3. Kill the Tower daemon itself
235+
const { execSync } = await import('node:child_process');
236+
const allPids = new Set<number>();
237+
238+
// Recursive function to collect entire subtree via pgrep -P
239+
function collectDescendants(pid: number): void {
240+
if (allPids.has(pid)) return;
241+
allPids.add(pid);
242+
try {
243+
const output = execSync(`pgrep -P ${pid}`, { encoding: 'utf-8' }).trim();
244+
for (const line of output.split('\n')) {
245+
const childPid = parseInt(line, 10);
246+
if (!isNaN(childPid)) collectDescendants(childPid);
247+
}
248+
} catch { /* no children */ }
249+
}
250+
251+
// Collect Tower daemon PIDs
252+
for (const pid of pids) {
253+
collectDescendants(pid);
254+
}
255+
256+
// Collect ALL shellper processes and their descendants (claude, bash, etc.)
257+
try {
258+
const shellperOutput = execSync('pgrep -f shellper-main', { encoding: 'utf-8' }).trim();
259+
for (const line of shellperOutput.split('\n')) {
260+
const pid = parseInt(line, 10);
261+
if (!isNaN(pid)) collectDescendants(pid);
262+
}
263+
} catch { /* no shellper processes */ }
264+
265+
// Kill leaves first (reverse order: deepest descendants → root)
266+
const orderedPids = [...allPids].reverse();
267+
let killed = 0;
268+
for (const pid of orderedPids) {
269+
try {
270+
process.kill(pid, 'SIGKILL');
271+
killed++;
272+
} catch { /* already dead */ }
273+
}
274+
275+
logger.success(`Force-killed ${killed} process(es) (tower + ${orderedPids.length - pids.length} shellper/children)`);
276+
return;
277+
}
278+
227279
let stopped = 0;
228280
for (const pid of pids) {
229281
try {

packages/codev/src/agent-farm/servers/tower-instances.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,19 @@ export async function getInstances(): Promise<InstanceStatus[]> {
200200
});
201201
}
202202

203-
// Sort: running first, then by workspace name
203+
// Sort: running first (alphabetical), then non-running by most recently used
204204
instances.sort((a, b) => {
205205
if (a.running !== b.running) {
206206
return a.running ? -1 : 1;
207207
}
208-
return a.workspaceName.localeCompare(b.workspaceName);
208+
if (a.running) {
209+
// Running: alphabetical
210+
return a.workspaceName.localeCompare(b.workspaceName);
211+
}
212+
// Non-running (recent): reverse chronological (most recently used first)
213+
const aTime = a.lastUsed ? new Date(a.lastUsed).getTime() : 0;
214+
const bTime = b.lastUsed ? new Date(b.lastUsed).getTime() : 0;
215+
return bTime - aTime;
209216
});
210217

211218
return instances;

packages/codev/src/agent-farm/servers/tower-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ const routeCtx: RouteContext = {
291291
// Hard cap: evict oldest connections when over limit to prevent
292292
// unbounded accumulation (tunnel-proxied EventSource reconnects
293293
// can leak because TCP close doesn't propagate reliably).
294-
const SSE_MAX_CLIENTS = 12;
294+
const SSE_MAX_CLIENTS = 50;
295295
while (sseClients.length >= SSE_MAX_CLIENTS) {
296296
const oldest = sseClients.shift();
297297
if (oldest) {

packages/codev/src/lib/team.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ export function parseFrontmatter(content: string): Record<string, unknown> | nul
7070
// Team Members
7171
// =============================================================================
7272

73-
const GITHUB_HANDLE_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
73+
// GitHub's documented rules say no trailing hyphens or consecutive hyphens,
74+
// but real accounts like "timeleft--" exist. Match what GitHub actually allows:
75+
// starts with alphanumeric, then any mix of alphanumeric and hyphens.
76+
const GITHUB_HANDLE_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
7477

7578
export function isValidGitHubHandle(handle: string): boolean {
7679
return GITHUB_HANDLE_RE.test(handle) && handle.length <= 39;

0 commit comments

Comments
 (0)