Skip to content

Commit c1f9518

Browse files
authored
Merge pull request #10285 from uinstinct/cli-background-bash-exec
feat: bash tool execution in background
2 parents 8eee3e0 + 87a0464 commit c1f9518

23 files changed

Lines changed: 736 additions & 10 deletions

extensions/cli/src/commands/commands.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { type AssistantConfig } from "@continuedev/sdk";
22

33
// Export command functions
44
export { chat } from "./chat.js";
5-
export { review } from "./review.js";
65
export { login } from "./login.js";
76
export { logout } from "./logout.js";
87
export { listSessionsCommand } from "./ls.js";
98
export { remote } from "./remote.js";
9+
export { review } from "./review.js";
1010
export { serve } from "./serve.js";
1111

1212
export interface SlashCommand {
@@ -101,6 +101,11 @@ export const SYSTEM_SLASH_COMMANDS: SystemCommand[] = [
101101
description: "Exit the chat",
102102
category: "system",
103103
},
104+
{
105+
name: "jobs",
106+
description: "List background jobs",
107+
category: "system",
108+
},
104109
];
105110

106111
// Remote mode specific commands
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { ChildProcess, spawn } from "child_process";
2+
3+
import { logger } from "../util/logger.js";
4+
5+
export type BackgroundJobStatus =
6+
| "pending"
7+
| "running"
8+
| "completed"
9+
| "failed"
10+
| "cancelled";
11+
12+
export interface BackgroundJob {
13+
id: string;
14+
status: BackgroundJobStatus;
15+
command: string;
16+
output: string;
17+
exitCode: number | null;
18+
startTime: Date;
19+
endTime: Date | null;
20+
error?: string;
21+
}
22+
23+
const MAX_CONCURRENT_JOBS = 5;
24+
const MAX_OUTPUT_LINES = 1000;
25+
26+
/**
27+
* Service for managing background job execution and lifecycle
28+
* Handles spawning, tracking, and cleanup of background processes
29+
*/
30+
export class BackgroundJobService {
31+
private jobs: Map<string, BackgroundJob> = new Map();
32+
private processes: Map<string, ChildProcess> = new Map();
33+
private jobCounter = 0;
34+
35+
createJob(command: string): BackgroundJob | null {
36+
const runningCount = this.getRunningJobCount();
37+
if (runningCount >= MAX_CONCURRENT_JOBS) {
38+
logger.warn(
39+
`Cannot create background job: limit of ${MAX_CONCURRENT_JOBS} reached`,
40+
);
41+
return null;
42+
}
43+
44+
const id = `bg-${++this.jobCounter}-${Date.now()}`;
45+
const job: BackgroundJob = {
46+
id,
47+
status: "pending",
48+
command,
49+
output: "",
50+
exitCode: null,
51+
startTime: new Date(),
52+
endTime: null,
53+
};
54+
55+
this.jobs.set(id, job);
56+
return job;
57+
}
58+
59+
startJob(jobId: string, shell: string, args: string[]): ChildProcess | null {
60+
const job = this.jobs.get(jobId);
61+
if (!job) {
62+
logger.error(`Cannot start job ${jobId}: job not found`);
63+
return null;
64+
}
65+
66+
job.status = "running";
67+
68+
const child = spawn(shell, args, { stdio: "pipe" });
69+
this.processes.set(jobId, child);
70+
71+
child.stdout?.setEncoding("utf8");
72+
child.stderr?.setEncoding("utf8");
73+
74+
child.stdout?.on("data", (data: string) => {
75+
this.appendOutput(jobId, data);
76+
});
77+
78+
child.stderr?.on("data", (data: string) => {
79+
this.appendOutput(jobId, data);
80+
});
81+
82+
child.on("close", (code: number | null) => {
83+
this.completeJob(jobId, code ?? 0);
84+
});
85+
86+
child.on("error", (error: Error) => {
87+
this.failJob(jobId, error.message);
88+
});
89+
90+
return child;
91+
}
92+
93+
createJobWithProcess(
94+
command: string,
95+
child: ChildProcess,
96+
existingOutput: string = "",
97+
): BackgroundJob | null {
98+
const runningCount = this.getRunningJobCount();
99+
if (runningCount >= MAX_CONCURRENT_JOBS) {
100+
logger.warn(
101+
`Cannot create background job: limit of ${MAX_CONCURRENT_JOBS} reached`,
102+
);
103+
return null;
104+
}
105+
106+
const id = `bg-${++this.jobCounter}-${Date.now()}`;
107+
const job: BackgroundJob = {
108+
id,
109+
status: "running",
110+
command,
111+
output: existingOutput,
112+
exitCode: null,
113+
startTime: new Date(),
114+
endTime: null,
115+
};
116+
117+
this.jobs.set(id, job);
118+
this.processes.set(id, child);
119+
120+
child.stdout?.setEncoding("utf8");
121+
child.stderr?.setEncoding("utf8");
122+
123+
child.stdout?.on("data", (data: string) => {
124+
this.appendOutput(id, data);
125+
});
126+
127+
child.stderr?.on("data", (data: string) => {
128+
this.appendOutput(id, data);
129+
});
130+
131+
child.on("close", (code: number | null) => {
132+
this.completeJob(id, code ?? 0);
133+
});
134+
135+
child.on("error", (error: Error) => {
136+
this.failJob(id, error.message);
137+
});
138+
139+
return job;
140+
}
141+
142+
// todo: improve write efficiency with ring buffer or similar
143+
appendOutput(jobId: string, data: string): void {
144+
const job = this.jobs.get(jobId);
145+
if (job) {
146+
job.output += data;
147+
const lines = job.output.split("\n");
148+
if (lines.length > MAX_OUTPUT_LINES) {
149+
job.output = lines.slice(-MAX_OUTPUT_LINES).join("\n");
150+
}
151+
}
152+
}
153+
154+
completeJob(jobId: string, exitCode: number): void {
155+
const job = this.jobs.get(jobId);
156+
if (job) {
157+
if (job.status === "cancelled") {
158+
return;
159+
}
160+
job.status = exitCode === 0 ? "completed" : "failed";
161+
job.exitCode = exitCode;
162+
job.endTime = new Date();
163+
this.processes.delete(jobId);
164+
}
165+
}
166+
167+
failJob(jobId: string, error: string): void {
168+
const job = this.jobs.get(jobId);
169+
if (job) {
170+
job.status = "failed";
171+
job.error = error;
172+
job.endTime = new Date();
173+
this.processes.delete(jobId);
174+
}
175+
}
176+
177+
cancelJob(jobId: string): boolean {
178+
const job = this.jobs.get(jobId);
179+
const process = this.processes.get(jobId);
180+
181+
if (!job) return false;
182+
183+
if (process) {
184+
process.kill();
185+
this.processes.delete(jobId);
186+
}
187+
188+
job.status = "cancelled";
189+
job.endTime = new Date();
190+
return true;
191+
}
192+
193+
getJob(jobId: string): BackgroundJob | undefined {
194+
return this.jobs.get(jobId);
195+
}
196+
197+
getRunningJobs(): BackgroundJob[] {
198+
return Array.from(this.jobs.values()).filter(
199+
(job) => job.status === "running" || job.status === "pending",
200+
);
201+
}
202+
203+
getAllJobs(): BackgroundJob[] {
204+
return Array.from(this.jobs.values());
205+
}
206+
207+
getRunningJobCount(): number {
208+
return this.getRunningJobs().length;
209+
}
210+
211+
killAllJobs(): void {
212+
for (const [jobId, process] of this.processes) {
213+
process.kill();
214+
const job = this.jobs.get(jobId);
215+
if (job) {
216+
job.status = "cancelled";
217+
job.endTime = new Date();
218+
}
219+
}
220+
this.processes.clear();
221+
}
222+
}
223+
224+
export const backgroundJobService = new BackgroundJobService();

extensions/cli/src/services/ToolPermissionService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
AUTO_MODE_POLICIES,
33
PLAN_MODE_POLICIES,
44
} from "src/permissions/defaultPolicies.js";
5-
import { ALL_BUILT_IN_TOOLS } from "src/tools/allBuiltIns.js";
65

76
import { ensurePermissionsYamlExists } from "../permissions/permissionsYamlLoader.js";
87
import { resolvePermissionPrecedence } from "../permissions/precedenceResolver.js";
@@ -11,6 +10,7 @@ import {
1110
ToolPermissionPolicy,
1211
ToolPermissions,
1312
} from "../permissions/types.js";
13+
import { BUILT_IN_TOOL_NAMES } from "../tools/builtInToolNames.js";
1414
import { logger } from "../util/logger.js";
1515

1616
import { BaseService, ServiceWithDependencies } from "./BaseService.js";
@@ -147,7 +147,7 @@ export class ToolPermissionService
147147
}));
148148
policies.push(...allowed);
149149
const specificBuiltInSet = new Set(specificBuiltIns);
150-
const notMentioned = ALL_BUILT_IN_TOOLS.map((t) => t.name).filter(
150+
const notMentioned = BUILT_IN_TOOL_NAMES.filter(
151151
(name) => !specificBuiltInSet.has(name),
152152
);
153153
const disallowed: ToolPermissionPolicy[] = notMentioned.map((tool) => ({

extensions/cli/src/services/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AgentFileService } from "./AgentFileService.js";
1010
import { ApiClientService } from "./ApiClientService.js";
1111
import { ArtifactUploadService } from "./ArtifactUploadService.js";
1212
import { AuthService } from "./AuthService.js";
13+
import { backgroundJobService } from "./BackgroundJobService.js";
1314
import { ChatHistoryService } from "./ChatHistoryService.js";
1415
import { ConfigService } from "./ConfigService.js";
1516
import { FileIndexService } from "./FileIndexService.js";
@@ -387,6 +388,7 @@ export const services = {
387388
toolPermissions: toolPermissionService,
388389
artifactUpload: artifactUploadService,
389390
gitAiIntegration: gitAiIntegrationService,
391+
backgroundJobs: backgroundJobService,
390392
} as const;
391393

392394
export type ServicesType = typeof services;

extensions/cli/src/services/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ export interface ArtifactUploadServiceState {
130130
lastError: string | null;
131131
}
132132

133+
export type {
134+
BackgroundJob,
135+
BackgroundJobStatus,
136+
} from "./BackgroundJobService.js";
133137
export type { ChatHistoryState } from "./ChatHistoryService.js";
134138
export type { FileIndexServiceState } from "./FileIndexService.js";
135139
export type { GitAiIntegrationServiceState } from "./GitAiIntegrationService.js";
@@ -153,6 +157,7 @@ export const SERVICE_NAMES = {
153157
AGENT_FILE: "agentFile",
154158
ARTIFACT_UPLOAD: "artifactUpload",
155159
GIT_AI_INTEGRATION: "gitAiIntegration",
160+
BACKGROUND_JOBS: "backgroundJobs",
156161
} as const;
157162

158163
/**

extensions/cli/src/slashCommands.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ function handleTitle(args: string[]) {
169169
}
170170
}
171171

172+
function handleJobs() {
173+
return { openJobsSelector: true };
174+
}
175+
172176
const commandHandlers: Record<string, CommandHandler> = {
173177
help: handleHelp,
174178
clear: () => {
@@ -203,6 +207,7 @@ const commandHandlers: Record<string, CommandHandler> = {
203207
update: () => {
204208
return { openUpdateSelector: true };
205209
},
210+
jobs: handleJobs,
206211
};
207212

208213
export async function handleSlashCommands(
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Static list of built-in tool names.
3+
* Kept separate from allBuiltIns.ts to avoid circular dependency:
4+
* ToolPermissionService -> allBuiltIns -> runTerminalCommand -> services/index -> ToolPermissionService
5+
*
6+
* When adding a new built-in tool, update both this list and ALL_BUILT_IN_TOOLS in allBuiltIns.ts.
7+
*/
8+
export const BUILT_IN_TOOL_NAMES = [
9+
"Edit",
10+
"Exit",
11+
"Fetch",
12+
"List",
13+
"MultiEdit",
14+
"Read",
15+
"ReportFailure",
16+
"Bash",
17+
"Search",
18+
"Status",
19+
"Subagent",
20+
"Skills",
21+
"UploadArtifact",
22+
"Diff",
23+
"Checklist",
24+
"Write",
25+
] as const;

0 commit comments

Comments
 (0)