@cjhyy/code-shell-capability-coding 0.8.0
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/README.md +69 -0
- package/THIRD_PARTY_NOTICES.md +206 -0
- package/dist/bin/agent-server-stdio.d.ts +1 -0
- package/dist/bin/agent-server-stdio.js +4 -0
- package/dist/capability-runtime.d.ts +22 -0
- package/dist/capability-runtime.js +88 -0
- package/dist/cc-orchestrator/agent-adapter.d.ts +55 -0
- package/dist/cc-orchestrator/agent-adapter.js +143 -0
- package/dist/cc-orchestrator/cc-capability.d.ts +20 -0
- package/dist/cc-orchestrator/cc-capability.js +57 -0
- package/dist/cc-orchestrator/codex-session-discovery.d.ts +45 -0
- package/dist/cc-orchestrator/codex-session-discovery.js +251 -0
- package/dist/cc-orchestrator/codex-session-history.d.ts +39 -0
- package/dist/cc-orchestrator/codex-session-history.js +249 -0
- package/dist/cc-orchestrator/codex-user-text.d.ts +9 -0
- package/dist/cc-orchestrator/codex-user-text.js +25 -0
- package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
- package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
- package/dist/cc-orchestrator/external-agent-changes.d.ts +19 -0
- package/dist/cc-orchestrator/external-agent-changes.js +231 -0
- package/dist/cc-orchestrator/external-agent-driver.d.ts +19 -0
- package/dist/cc-orchestrator/external-agent-driver.js +284 -0
- package/dist/cc-orchestrator/external-agent-session-store.d.ts +36 -0
- package/dist/cc-orchestrator/external-agent-session-store.js +185 -0
- package/dist/cc-orchestrator/index.d.ts +10 -0
- package/dist/cc-orchestrator/index.js +10 -0
- package/dist/cc-orchestrator/related-session-discovery.d.ts +21 -0
- package/dist/cc-orchestrator/related-session-discovery.js +77 -0
- package/dist/cc-orchestrator/relevance-judge.d.ts +16 -0
- package/dist/cc-orchestrator/relevance-judge.js +38 -0
- package/dist/cc-orchestrator/session-discovery.d.ts +99 -0
- package/dist/cc-orchestrator/session-discovery.js +295 -0
- package/dist/cc-orchestrator/session-history.d.ts +55 -0
- package/dist/cc-orchestrator/session-history.js +150 -0
- package/dist/external-agents/config.d.ts +3 -0
- package/dist/external-agents/config.js +16 -0
- package/dist/external-agents/types.d.ts +32 -0
- package/dist/external-agents/types.js +1 -0
- package/dist/external-runtimes/claude-code/event-translator.d.ts +54 -0
- package/dist/external-runtimes/claude-code/event-translator.js +201 -0
- package/dist/external-runtimes/claude-code/mcp-config.d.ts +84 -0
- package/dist/external-runtimes/claude-code/mcp-config.js +175 -0
- package/dist/external-runtimes/claude-code/runtime.d.ts +56 -0
- package/dist/external-runtimes/claude-code/runtime.js +168 -0
- package/dist/external-runtimes/codex/app-server-client.d.ts +66 -0
- package/dist/external-runtimes/codex/app-server-client.js +244 -0
- package/dist/external-runtimes/codex/event-translator.d.ts +68 -0
- package/dist/external-runtimes/codex/event-translator.js +187 -0
- package/dist/external-runtimes/codex/index.d.ts +7 -0
- package/dist/external-runtimes/codex/index.js +6 -0
- package/dist/external-runtimes/codex/runtime.d.ts +128 -0
- package/dist/external-runtimes/codex/runtime.js +216 -0
- package/dist/external-runtimes/index.d.ts +37 -0
- package/dist/external-runtimes/index.js +27 -0
- package/dist/external-runtimes/session-factory.d.ts +97 -0
- package/dist/external-runtimes/session-factory.js +110 -0
- package/dist/external-runtimes/shared/mcp-bridge.d.ts +66 -0
- package/dist/external-runtimes/shared/mcp-bridge.js +365 -0
- package/dist/external-runtimes/shared/session-context-store.d.ts +83 -0
- package/dist/external-runtimes/shared/session-context-store.js +114 -0
- package/dist/external-runtimes/shared/spawn-env.d.ts +30 -0
- package/dist/external-runtimes/shared/spawn-env.js +41 -0
- package/dist/git/parse-log.d.ts +14 -0
- package/dist/git/parse-log.js +21 -0
- package/dist/git/utils.d.ts +49 -0
- package/dist/git/utils.js +161 -0
- package/dist/git/worktree/crud.d.ts +108 -0
- package/dist/git/worktree/crud.js +415 -0
- package/dist/git/worktree/diff.d.ts +14 -0
- package/dist/git/worktree/diff.js +83 -0
- package/dist/git/worktree/git-exec.d.ts +7 -0
- package/dist/git/worktree/git-exec.js +86 -0
- package/dist/git/worktree/index.d.ts +6 -0
- package/dist/git/worktree/index.js +6 -0
- package/dist/git/worktree/query.d.ts +43 -0
- package/dist/git/worktree/query.js +128 -0
- package/dist/git/worktree/slug.d.ts +12 -0
- package/dist/git/worktree/slug.js +59 -0
- package/dist/git/worktree.d.ts +1 -0
- package/dist/git/worktree.js +5 -0
- package/dist/index.capability.d.ts +7 -0
- package/dist/index.capability.js +190 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.git.d.ts +4 -0
- package/dist/index.git.js +4 -0
- package/dist/index.js +18 -0
- package/dist/index.orchestration.d.ts +7 -0
- package/dist/index.orchestration.js +5 -0
- package/dist/lsp/client.d.ts +41 -0
- package/dist/lsp/client.js +192 -0
- package/dist/lsp/manager.d.ts +53 -0
- package/dist/lsp/manager.js +248 -0
- package/dist/lsp/root-path.d.ts +9 -0
- package/dist/lsp/root-path.js +12 -0
- package/dist/lsp/servers.d.ts +16 -0
- package/dist/lsp/servers.js +60 -0
- package/dist/prompt/coding.md +42 -0
- package/dist/quota/credentials.d.ts +3 -0
- package/dist/quota/credentials.js +80 -0
- package/dist/quota/index.d.ts +36 -0
- package/dist/quota/index.js +161 -0
- package/dist/quota/types.d.ts +48 -0
- package/dist/quota/types.js +13 -0
- package/dist/review/review-prompt.d.ts +28 -0
- package/dist/review/review-prompt.js +81 -0
- package/dist/tools/apply-patch/LICENSE-codex +201 -0
- package/dist/tools/apply-patch/NOTICE.md +29 -0
- package/dist/tools/apply-patch/applier.d.ts +26 -0
- package/dist/tools/apply-patch/applier.js +306 -0
- package/dist/tools/apply-patch/backup-targets.d.ts +10 -0
- package/dist/tools/apply-patch/backup-targets.js +30 -0
- package/dist/tools/apply-patch/eol.d.ts +4 -0
- package/dist/tools/apply-patch/eol.js +9 -0
- package/dist/tools/apply-patch/index.d.ts +20 -0
- package/dist/tools/apply-patch/index.js +106 -0
- package/dist/tools/apply-patch/parser.d.ts +17 -0
- package/dist/tools/apply-patch/parser.js +209 -0
- package/dist/tools/apply-patch/seek-sequence.d.ts +18 -0
- package/dist/tools/apply-patch/seek-sequence.js +123 -0
- package/dist/tools/apply-patch/types.d.ts +49 -0
- package/dist/tools/apply-patch/types.js +13 -0
- package/dist/tools/brief.d.ts +6 -0
- package/dist/tools/brief.js +41 -0
- package/dist/tools/check-quota.d.ts +14 -0
- package/dist/tools/check-quota.js +38 -0
- package/dist/tools/drive-agent.d.ts +52 -0
- package/dist/tools/drive-agent.js +1184 -0
- package/dist/tools/lsp.d.ts +6 -0
- package/dist/tools/lsp.js +220 -0
- package/dist/tools/notebook-edit.d.ts +6 -0
- package/dist/tools/notebook-edit.js +127 -0
- package/dist/tools/worktree.d.ts +10 -0
- package/dist/tools/worktree.js +363 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
import { backgroundJobRegistry, isExistingDirectory, logger, normalizeCwdPath, notificationQueue, } from "@cjhyy/code-shell-core/extension";
|
|
2
|
+
import { runAgentOnce } from "../cc-orchestrator/external-agent-driver.js";
|
|
3
|
+
import { claudeAdapter, codexAdapter } from "../cc-orchestrator/agent-adapter.js";
|
|
4
|
+
import { readExternalChangedFiles } from "../cc-orchestrator/external-agent-changes.js";
|
|
5
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
6
|
+
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { externalAgentSessionStore, } from "../cc-orchestrator/external-agent-session-store.js";
|
|
8
|
+
import { branchExists, createWorktree, findGitRoot, inspectWorktreeChanges, lockWorktree, removeWorktree, unlockWorktree, } from "../git/worktree.js";
|
|
9
|
+
import { codingToolService } from "../capability-runtime.js";
|
|
10
|
+
export const DRIVE_AGENT_FOREGROUND_HANDOFF_MS = 110_000;
|
|
11
|
+
export const DRIVE_AGENT_TOOL_TIMEOUT_MS = 1_800_000;
|
|
12
|
+
const CLI_ADAPTERS = {
|
|
13
|
+
claude: { adapter: claudeAdapter, command: "claude" },
|
|
14
|
+
codex: { adapter: codexAdapter, command: "codex" },
|
|
15
|
+
};
|
|
16
|
+
export const driveAgentToolDef = {
|
|
17
|
+
name: "DriveAgent",
|
|
18
|
+
description: "Delegate a task to an external coding-agent CLI — drives `claude` (Claude Code) or `codex` " +
|
|
19
|
+
"(OpenAI Codex) for one turn. Pick with `cli` (defaults to claude). Use to hand a coding/" +
|
|
20
|
+
"research task to the agent, or continue an existing session of it. " +
|
|
21
|
+
"Runs in the BACKGROUND by default — these tasks are typically long (minutes to hours), so this " +
|
|
22
|
+
"returns immediately and the result is delivered to you later via a completion notification " +
|
|
23
|
+
"that wakes you. Do NOT sleep-poll for it; just continue or end your turn — you'll be woken " +
|
|
24
|
+
"with the result. " +
|
|
25
|
+
"Before launching writable work in a cwd, call DriveAgentJobs(action:'list', cwd) to see any " +
|
|
26
|
+
"already-running DriveAgent jobs there, their prompt summaries, owner sessions, CLI kind, and " +
|
|
27
|
+
"known changed files; use DriveAgentJobs(action:'cancel', jobId) if you must stop one. " +
|
|
28
|
+
"If the external agent is explicitly expected to write a workspace other than its launch cwd, " +
|
|
29
|
+
"set effectiveWorkspaceCwd so cross-session conflict checks use that declared workspace; omit " +
|
|
30
|
+
"it when the run writes in cwd. " +
|
|
31
|
+
"For a quick task where you want the answer inline, pass background:false. " +
|
|
32
|
+
"It has NO time concept of its own: for 'in N minutes' / 'every N' / looping, use CronCreate " +
|
|
33
|
+
"instead (never sleep). A scheduled CronCreate job runs one codeshell turn whose prompt can " +
|
|
34
|
+
"instruct it to call DriveAgent; to continue a prior session across runs, have that turn pass " +
|
|
35
|
+
"the sessionId this tool returned as resumeSessionId (same cli). To make the single turn work " +
|
|
36
|
+
"longer/deeper, write that into `prompt` (e.g. 'keep working until done'). " +
|
|
37
|
+
"Pass `resumeSessionId` to continue a prior session of the SAME cli (keeps context); omit to start fresh.\n" +
|
|
38
|
+
"SCOPE & BUDGET (read before fanning out): each driven agent consumes a large, SHARED token budget. " +
|
|
39
|
+
"Do NOT launch many agents at once to 'cover more ground' — that burns the budget and leaves every " +
|
|
40
|
+
"task half-done. Default to ONE agent at a time; only run a few in parallel when the work TRULY splits " +
|
|
41
|
+
"into independent pieces, and even then keep the count small (≈2-3). Prefer finishing one task before " +
|
|
42
|
+
"starting the next. " +
|
|
43
|
+
"Make each `prompt` a COMPLETE, self-contained task that instructs the agent to finish the whole thing " +
|
|
44
|
+
"end-to-end and verify its own work before returning — never a vague 'start looking into X'. " +
|
|
45
|
+
"Give it a concrete definition of done and, for open-ended work, a bound (files/scope/steps) so it " +
|
|
46
|
+
"doesn't sprawl. If a big job must be split, split it into a SEQUENCE you drive one at a time (resume " +
|
|
47
|
+
"the same session), not a swarm launched simultaneously.",
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
prompt: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "The task for the agent. Make it COMPLETE and self-contained: state the goal, a concrete definition of done, and (for open-ended work) an explicit scope bound so it finishes end-to-end and verifies its own work rather than sprawling or stopping half-way.",
|
|
54
|
+
},
|
|
55
|
+
cli: {
|
|
56
|
+
type: "string",
|
|
57
|
+
enum: ["claude", "codex"],
|
|
58
|
+
description: "Which external CLI to drive. 'claude' = Claude Code (default), 'codex' = OpenAI Codex. resumeSessionId is only valid against the same cli that produced it.",
|
|
59
|
+
},
|
|
60
|
+
resumeSessionId: {
|
|
61
|
+
type: "string",
|
|
62
|
+
description: "Existing session id to resume (keeps context). Must come from a prior run of the SAME cli. Omit for a fresh session.",
|
|
63
|
+
},
|
|
64
|
+
model: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "Optional model override, passed through to `claude --model` / `codex exec --model`. Omit to use the CLI default; only pass when the user explicitly requests a model.",
|
|
67
|
+
},
|
|
68
|
+
cwd: { type: "string", description: "Working directory the run operates in." },
|
|
69
|
+
isolation: {
|
|
70
|
+
type: "string",
|
|
71
|
+
enum: ["current", "worktree", "none"],
|
|
72
|
+
description: "Workspace isolation. 'worktree' creates a unique per-run git worktree; 'current' uses the CodeShell session workspace; 'none' uses cwd directly. By default the first run uses cwd directly, while a parallel writable run targeting an already-busy workspace is automatically isolated; resumes reuse their bound workspace.",
|
|
73
|
+
},
|
|
74
|
+
baseRef: {
|
|
75
|
+
type: "string",
|
|
76
|
+
description: "Base for a new isolated worktree: 'head' (default), 'fresh' (locally-known origin/HEAD), or an explicit branch/ref/commit.",
|
|
77
|
+
},
|
|
78
|
+
include: {
|
|
79
|
+
type: "array",
|
|
80
|
+
items: { type: "string" },
|
|
81
|
+
description: "Extra gitignore-style patterns to copy into a new worktree. Combined with repository .worktreeinclude; intended for ignored env/config files.",
|
|
82
|
+
},
|
|
83
|
+
cleanup: {
|
|
84
|
+
type: "string",
|
|
85
|
+
enum: ["auto", "keep", "detach", "discard"],
|
|
86
|
+
description: "New worktree completion policy. auto (default) discards a clean/no-commit worktree and keeps one with changes; keep preserves directory+branch; detach removes the directory only when uncommitted changes are absent and preserves the branch; discard always deletes directory+branch.",
|
|
87
|
+
},
|
|
88
|
+
effectiveWorkspaceCwd: {
|
|
89
|
+
type: "string",
|
|
90
|
+
description: "Optional declared workspace the external agent is expected to write when that differs from its launch cwd (for example, an explicitly managed worktree it will cd into). Used for conflict detection only; the CLI still launches in cwd. Defaults to cwd.",
|
|
91
|
+
},
|
|
92
|
+
attachmentPaths: {
|
|
93
|
+
type: "array",
|
|
94
|
+
items: { type: "string" },
|
|
95
|
+
description: "Optional local file paths to hand to the driven agent. Paths must resolve inside cwd. Images are also passed to Codex with -i when the installed Codex CLI supports it; otherwise all paths are listed in the prompt.",
|
|
96
|
+
},
|
|
97
|
+
permissionMode: {
|
|
98
|
+
type: "string",
|
|
99
|
+
enum: ["default", "acceptEdits", "bypassPermissions"],
|
|
100
|
+
description: "Permission/sandbox level. Defaults to 'bypassPermissions' (full auto — needed so the agent's tools run unattended; there is no interactive approval loop here). For codex: default→read-only sandbox, acceptEdits→workspace-write, bypassPermissions→no sandbox. Pass 'default'/'acceptEdits' to gate.",
|
|
101
|
+
},
|
|
102
|
+
background: {
|
|
103
|
+
type: "boolean",
|
|
104
|
+
description: "Defaults to TRUE: run in the background and notify you on completion (right for long tasks). Pass false to run in the foreground and get the result inline (only for quick tasks).",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
required: ["prompt"],
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const defaultRunner = (opts) => {
|
|
111
|
+
const { adapter, command } = CLI_ADAPTERS[opts.cli];
|
|
112
|
+
return runAgentOnce(adapter, {
|
|
113
|
+
command,
|
|
114
|
+
prompt: opts.prompt,
|
|
115
|
+
resumeSessionId: opts.resumeSessionId,
|
|
116
|
+
model: opts.model,
|
|
117
|
+
cwd: opts.cwd,
|
|
118
|
+
permissionMode: opts.permissionMode ?? "default",
|
|
119
|
+
imagePaths: opts.imagePaths,
|
|
120
|
+
}, opts.signal);
|
|
121
|
+
};
|
|
122
|
+
function newDriveJobId() {
|
|
123
|
+
return `cc-${process.hrtime.bigint().toString(36)}`;
|
|
124
|
+
}
|
|
125
|
+
function newDriveRunSeed() {
|
|
126
|
+
return process.hrtime.bigint().toString(36).padStart(8, "0");
|
|
127
|
+
}
|
|
128
|
+
function driveIsolationArg(value) {
|
|
129
|
+
return value === "current" || value === "worktree" || value === "none" ? value : undefined;
|
|
130
|
+
}
|
|
131
|
+
function driveCleanupArg(value) {
|
|
132
|
+
return value === "keep" || value === "detach" || value === "discard" || value === "auto"
|
|
133
|
+
? value
|
|
134
|
+
: "auto";
|
|
135
|
+
}
|
|
136
|
+
function driveIncludeArg(value) {
|
|
137
|
+
if (value === undefined)
|
|
138
|
+
return {};
|
|
139
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) {
|
|
140
|
+
return { error: "include must be an array of non-empty gitignore-style patterns" };
|
|
141
|
+
}
|
|
142
|
+
return { patterns: value.map((item) => String(item).trim()) };
|
|
143
|
+
}
|
|
144
|
+
async function resolveDriveWorkspaceRoot(cwd) {
|
|
145
|
+
try {
|
|
146
|
+
return await findGitRoot(cwd);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return normalizeCwdPath(cwd);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function hasRunningDriveWriter(workspaceCwd, workspaceRoot) {
|
|
153
|
+
if (backgroundJobRegistry.listRunningByCwd(workspaceCwd).some(isDriveAgentJob))
|
|
154
|
+
return true;
|
|
155
|
+
return backgroundJobRegistry
|
|
156
|
+
.list()
|
|
157
|
+
.some((job) => (job.status === "running" || job.status === "cancelling") &&
|
|
158
|
+
isDriveAgentJob(job) &&
|
|
159
|
+
job.workspaceRoot === workspaceRoot);
|
|
160
|
+
}
|
|
161
|
+
async function prepareDriveWorktree(params) {
|
|
162
|
+
const gitRoot = await findGitRoot(params.cwd, params.signal);
|
|
163
|
+
const branchPrefix = codingToolService(params.ctx)?.readWorktreeBranchPrefix(gitRoot);
|
|
164
|
+
const session = await createWorktree(gitRoot, `drive-${params.cli}`, newDriveRunSeed(), {
|
|
165
|
+
prefix: branchPrefix,
|
|
166
|
+
signal: params.signal,
|
|
167
|
+
baseRef: params.baseRef,
|
|
168
|
+
include: params.include,
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
lockWorktree(session.worktreePath, `CodeShell DriveAgent ${params.cli} run`);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
removeWorktree(session.worktreePath, true, { prefix: branchPrefix });
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
return { session, cleanup: params.cleanup, branchPrefix };
|
|
178
|
+
}
|
|
179
|
+
function finalizeDriveWorktree(managed) {
|
|
180
|
+
const { session } = managed;
|
|
181
|
+
let state;
|
|
182
|
+
try {
|
|
183
|
+
state = inspectWorktreeChanges(session.worktreePath, session.baseRef);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
try {
|
|
187
|
+
unlockWorktree(session.worktreePath);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Preservation is still safer than deleting a worktree we could not inspect.
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
action: "kept",
|
|
194
|
+
note: `Worktree kept at ${session.worktreePath} (branch ${session.worktreeBranch}) because ` +
|
|
195
|
+
`change inspection failed: ${error instanceof Error ? error.message : String(error)}.`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
let action = managed.cleanup;
|
|
199
|
+
let safetyNote = "";
|
|
200
|
+
if (action === "auto")
|
|
201
|
+
action = state.hasChanges ? "keep" : "discard";
|
|
202
|
+
if (action === "detach" && state.uncommitted) {
|
|
203
|
+
action = "keep";
|
|
204
|
+
safetyNote = " Requested detach was changed to keep because uncommitted changes are present.";
|
|
205
|
+
}
|
|
206
|
+
unlockWorktree(session.worktreePath);
|
|
207
|
+
const summary = `uncommitted=${state.uncommitted}, commitsAhead=${state.commitsAhead}`;
|
|
208
|
+
if (action === "keep") {
|
|
209
|
+
return {
|
|
210
|
+
action: "kept",
|
|
211
|
+
note: `Worktree kept at ${session.worktreePath} (branch ${session.worktreeBranch}; ${summary}).` +
|
|
212
|
+
`${safetyNote} Resume the external session in this path, or clean it later with ` +
|
|
213
|
+
`DriveAgentJobs(action:"cleanup", cleanup:"detach"|"discard").`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (action === "detach") {
|
|
217
|
+
removeWorktree(session.worktreePath, false);
|
|
218
|
+
return {
|
|
219
|
+
action: "detached",
|
|
220
|
+
note: `Worktree directory removed; branch ${session.worktreeBranch} preserved (${summary}). ` +
|
|
221
|
+
`Review/merge the branch or recreate a worktree before resuming this external session.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const removal = removeWorktree(session.worktreePath, true, { prefix: managed.branchPrefix });
|
|
225
|
+
if (removal.branchDeleted === false) {
|
|
226
|
+
return {
|
|
227
|
+
action: "detached",
|
|
228
|
+
note: `Worktree directory removed, but branch ${removal.branch ?? session.worktreeBranch} was ` +
|
|
229
|
+
`preserved because deletion failed: ${removal.branchError ?? "unknown error"}.`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
action: "discarded",
|
|
234
|
+
note: `Clean worktree and branch ${session.worktreeBranch} removed (${summary}).`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function worktreeStartNote(managed) {
|
|
238
|
+
if (!managed)
|
|
239
|
+
return undefined;
|
|
240
|
+
const { session } = managed;
|
|
241
|
+
return (`Isolation worktree: ${session.worktreePath}\n` +
|
|
242
|
+
`Branch: ${session.worktreeBranch}\n` +
|
|
243
|
+
`Base: ${session.baseRefLabel ?? session.baseRef ?? "HEAD"}; copied ignored files: ${session.includedFiles?.length ?? 0}; cleanup: ${managed.cleanup}`);
|
|
244
|
+
}
|
|
245
|
+
function appendLifecycleNote(text, lifecycle) {
|
|
246
|
+
return lifecycle ? `${text}${text ? "\n\n" : ""}[worktree lifecycle] ${lifecycle.note}` : text;
|
|
247
|
+
}
|
|
248
|
+
function safeFinalizeDriveWorktree(managed) {
|
|
249
|
+
if (!managed)
|
|
250
|
+
return undefined;
|
|
251
|
+
try {
|
|
252
|
+
return finalizeDriveWorktree(managed);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
return {
|
|
256
|
+
action: "kept",
|
|
257
|
+
note: `Worktree cleanup failed; inspect ${managed.session.worktreePath} and branch ` +
|
|
258
|
+
`${managed.session.worktreeBranch} manually: ${error instanceof Error ? error.message : String(error)}.`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function summarizePrompt(prompt, max = 120) {
|
|
263
|
+
const oneLine = prompt.replace(/\s+/g, " ").trim();
|
|
264
|
+
if (oneLine.length <= max)
|
|
265
|
+
return oneLine;
|
|
266
|
+
return `${oneLine.slice(0, Math.max(0, max - 3))}...`;
|
|
267
|
+
}
|
|
268
|
+
function isDriveAgentJob(job) {
|
|
269
|
+
return job.kind === "drive-agent" || job.description.startsWith("DriveAgent(");
|
|
270
|
+
}
|
|
271
|
+
function changedFilesSummary(job) {
|
|
272
|
+
const files = job.changedFiles ?? [];
|
|
273
|
+
if (files.length === 0)
|
|
274
|
+
return "unknown";
|
|
275
|
+
if (files.length <= 4)
|
|
276
|
+
return files.join(",");
|
|
277
|
+
return `${files.slice(0, 4).join(",")} (+${files.length - 4} more)`;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Canonicalize external transcript paths against the DriveAgent cwd. Claude
|
|
281
|
+
* normally records absolute paths while Codex apply_patch normally records
|
|
282
|
+
* relative paths; returning a cwd-relative display path for in-workspace files
|
|
283
|
+
* gives the renderer one stable identity and removes duplicate transcript hits.
|
|
284
|
+
*/
|
|
285
|
+
function normalizeChangedFiles(cwd, files) {
|
|
286
|
+
const seen = new Set();
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const file of files) {
|
|
289
|
+
if (typeof file !== "string" || !file.trim())
|
|
290
|
+
continue;
|
|
291
|
+
const absolute = resolve(cwd, file.trim());
|
|
292
|
+
if (seen.has(absolute))
|
|
293
|
+
continue;
|
|
294
|
+
seen.add(absolute);
|
|
295
|
+
const rel = relative(cwd, absolute);
|
|
296
|
+
out.push(rel && rel !== ".." && !rel.startsWith(`..${sep}`) ? rel : absolute);
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
function jobDurationSeconds(job) {
|
|
301
|
+
const end = job.finishedAt ?? Date.now();
|
|
302
|
+
return `${Math.max(0, (end - job.startedAt) / 1000).toFixed(1)}s`;
|
|
303
|
+
}
|
|
304
|
+
function formatDriveJobListLine(job) {
|
|
305
|
+
const prompt = job.promptSummary || job.description || "(no prompt summary)";
|
|
306
|
+
const launchCwd = job.launchCwd ?? job.cwd ?? "(unknown cwd)";
|
|
307
|
+
const cli = job.cli ?? "unknown";
|
|
308
|
+
return [
|
|
309
|
+
`${job.jobId}`,
|
|
310
|
+
`status=${job.status}`,
|
|
311
|
+
`cli=${cli}`,
|
|
312
|
+
`session=${job.sessionId}`,
|
|
313
|
+
`launchCwd=${launchCwd}`,
|
|
314
|
+
...(job.worktreePath ? [`worktree=${job.worktreePath}`] : []),
|
|
315
|
+
...(job.worktreeBranch ? [`branch=${job.worktreeBranch}`] : []),
|
|
316
|
+
...(job.worktreeLifecycle ? [`lifecycle=${job.worktreeLifecycle}`] : []),
|
|
317
|
+
`startedAt=${new Date(job.startedAt).toISOString()}`,
|
|
318
|
+
`duration=${jobDurationSeconds(job)}`,
|
|
319
|
+
`changedFiles=${changedFilesSummary(job)}`,
|
|
320
|
+
`prompt="${prompt}"`,
|
|
321
|
+
].join(" ");
|
|
322
|
+
}
|
|
323
|
+
const DEFAULT_DRIVE_JOB_RESULT_CHARS = 800;
|
|
324
|
+
const DRIVE_JOB_RESULT_TRUNCATED_SUFFIX = "…(truncated, use inspect for full)";
|
|
325
|
+
function isTerminalDriveJob(job) {
|
|
326
|
+
return job.status === "completed" || job.status === "failed" || job.status === "cancelled";
|
|
327
|
+
}
|
|
328
|
+
function driveJobResultChars(value) {
|
|
329
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
330
|
+
return DEFAULT_DRIVE_JOB_RESULT_CHARS;
|
|
331
|
+
return Math.floor(value);
|
|
332
|
+
}
|
|
333
|
+
function truncateDriveJobResult(finalText, maxChars) {
|
|
334
|
+
const characters = Array.from(finalText);
|
|
335
|
+
if (characters.length <= maxChars)
|
|
336
|
+
return finalText;
|
|
337
|
+
return `${characters.slice(0, maxChars).join("")}${DRIVE_JOB_RESULT_TRUNCATED_SUFFIX}`;
|
|
338
|
+
}
|
|
339
|
+
function formatDriveJobListBlock(job, resultChars) {
|
|
340
|
+
const lines = [formatDriveJobListLine(job)];
|
|
341
|
+
if (isTerminalDriveJob(job) && resultChars > 0 && job.finalText) {
|
|
342
|
+
const resultLines = truncateDriveJobResult(job.finalText, resultChars)
|
|
343
|
+
.split("\n")
|
|
344
|
+
.map((line) => ` ${line}`);
|
|
345
|
+
lines.push("finalText:", ...resultLines);
|
|
346
|
+
}
|
|
347
|
+
return lines.join("\n");
|
|
348
|
+
}
|
|
349
|
+
function isValidSessionId(sessionId) {
|
|
350
|
+
return typeof sessionId === "string" && sessionId.length > 0;
|
|
351
|
+
}
|
|
352
|
+
function argSignal(args) {
|
|
353
|
+
const signal = args.__signal;
|
|
354
|
+
return signal &&
|
|
355
|
+
typeof signal === "object" &&
|
|
356
|
+
typeof signal.aborted === "boolean" &&
|
|
357
|
+
typeof signal.addEventListener === "function"
|
|
358
|
+
? signal
|
|
359
|
+
: undefined;
|
|
360
|
+
}
|
|
361
|
+
function startRun(runner, opts) {
|
|
362
|
+
return Promise.resolve().then(() => runner(opts));
|
|
363
|
+
}
|
|
364
|
+
function resolveAttachmentPaths(raw, cwd) {
|
|
365
|
+
if (raw === undefined)
|
|
366
|
+
return { paths: [] };
|
|
367
|
+
if (!Array.isArray(raw))
|
|
368
|
+
return { paths: [], error: "attachmentPaths must be an array of strings" };
|
|
369
|
+
const cwdReal = realpathSync(cwd);
|
|
370
|
+
const paths = [];
|
|
371
|
+
for (const item of raw) {
|
|
372
|
+
if (typeof item !== "string" || !item.trim()) {
|
|
373
|
+
return { paths: [], error: "attachmentPaths must contain only non-empty strings" };
|
|
374
|
+
}
|
|
375
|
+
const candidate = isAbsolute(item) ? item : resolve(cwd, item);
|
|
376
|
+
if (!existsSync(candidate))
|
|
377
|
+
return { paths: [], error: `attachment path not found: ${item}` };
|
|
378
|
+
const real = realpathSync(candidate);
|
|
379
|
+
const rel = relative(cwdReal, real);
|
|
380
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`)) {
|
|
381
|
+
return { paths: [], error: `attachment path is outside cwd: ${item}` };
|
|
382
|
+
}
|
|
383
|
+
const info = statSync(real);
|
|
384
|
+
if (!info.isFile())
|
|
385
|
+
return { paths: [], error: `attachment path is not a file: ${item}` };
|
|
386
|
+
paths.push(real);
|
|
387
|
+
}
|
|
388
|
+
return { paths };
|
|
389
|
+
}
|
|
390
|
+
const DRIVE_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
391
|
+
function appendAttachmentPrompt(prompt, paths) {
|
|
392
|
+
if (paths.length === 0)
|
|
393
|
+
return prompt;
|
|
394
|
+
const lines = ["", "Attached files:"];
|
|
395
|
+
for (const path of paths) {
|
|
396
|
+
const kind = DRIVE_IMAGE_EXTS.has(extname(path).toLowerCase()) ? "image" : "file";
|
|
397
|
+
lines.push(`- ${path} (${kind})`);
|
|
398
|
+
}
|
|
399
|
+
return `${prompt}\n${lines.join("\n")}`;
|
|
400
|
+
}
|
|
401
|
+
function recordSuccessfulSession(store, cli, cwd, result, metadata, includeErroredSession = false) {
|
|
402
|
+
if ((!includeErroredSession && result.isError) || !result.sessionId)
|
|
403
|
+
return;
|
|
404
|
+
// When the isolation worktree directory was removed during finalization the
|
|
405
|
+
// recorded cwd (the worktree path) no longer exists, so a later resume would
|
|
406
|
+
// be rejected even though the external CLI session — keyed by sessionId, not
|
|
407
|
+
// cwd — is still resumable. Rebind such runs to the workspace root, which
|
|
408
|
+
// still exists, and drop the dead worktree path. `keep` runs keep the
|
|
409
|
+
// worktree; `detach` keeps the branch for recreation but its directory is
|
|
410
|
+
// gone, so bind cwd to the workspace root while retaining the branch.
|
|
411
|
+
const dirRemoved = metadata.lifecycle?.action === "discarded" || metadata.lifecycle?.action === "detached";
|
|
412
|
+
const branchPreserved = metadata.lifecycle?.action === "detached";
|
|
413
|
+
const boundCwd = dirRemoved ? metadata.workspaceRoot : cwd;
|
|
414
|
+
const boundIsolation = dirRemoved && metadata.isolation === "worktree" ? "current" : metadata.isolation;
|
|
415
|
+
const worktreeBinding = metadata.worktree && !dirRemoved
|
|
416
|
+
? {
|
|
417
|
+
worktreePath: metadata.worktree.session.worktreePath,
|
|
418
|
+
worktreeBranch: metadata.worktree.session.worktreeBranch,
|
|
419
|
+
worktreeBaseRef: metadata.worktree.session.baseRef,
|
|
420
|
+
}
|
|
421
|
+
: metadata.worktree && branchPreserved
|
|
422
|
+
? { worktreeBranch: metadata.worktree.session.worktreeBranch }
|
|
423
|
+
: {};
|
|
424
|
+
try {
|
|
425
|
+
store.record({
|
|
426
|
+
cli,
|
|
427
|
+
sessionId: result.sessionId,
|
|
428
|
+
...(metadata.codeShellSessionId ? { codeShellSessionId: metadata.codeShellSessionId } : {}),
|
|
429
|
+
cwd: boundCwd,
|
|
430
|
+
workspaceRoot: metadata.workspaceRoot,
|
|
431
|
+
isolation: boundIsolation,
|
|
432
|
+
...worktreeBinding,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
logger.warn("drive_agent.session_binding_record_failed", {
|
|
437
|
+
cat: "cc",
|
|
438
|
+
cli,
|
|
439
|
+
sessionId: result.sessionId,
|
|
440
|
+
cwd,
|
|
441
|
+
error: err instanceof Error ? err.message : String(err),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function duplicateCwdWarning(effectiveWorkspaceCwd, writable) {
|
|
446
|
+
if (!writable)
|
|
447
|
+
return undefined;
|
|
448
|
+
const running = backgroundJobRegistry
|
|
449
|
+
.listRunningByCwd(effectiveWorkspaceCwd)
|
|
450
|
+
.filter(isDriveAgentJob);
|
|
451
|
+
if (running.length === 0)
|
|
452
|
+
return undefined;
|
|
453
|
+
const jobs = running.map(formatDriveJobListLine).join("; ");
|
|
454
|
+
const workspaceRoot = running[0]?.effectiveWorkspaceRoot ?? normalizeCwdPath(effectiveWorkspaceCwd);
|
|
455
|
+
return (`Warning: another DriveAgent job is already running in effective workspace ${workspaceRoot}. ` +
|
|
456
|
+
"Concurrent writable agents in the same workspace can overwrite each other's work. " +
|
|
457
|
+
`Run DriveAgentJobs(action:"list", cwd:"${effectiveWorkspaceCwd}") before dispatching parallel work for details/cancellation. ` +
|
|
458
|
+
`Running: ${jobs}`);
|
|
459
|
+
}
|
|
460
|
+
function attachDriveCompletion(params) {
|
|
461
|
+
const { jobId, sessionId, label, cli, cwd, workspaceRoot, isolation, worktree, run, sessionStore, readChangedFiles, recordExternalFileChanges, originClientMessageId, } = params;
|
|
462
|
+
void run
|
|
463
|
+
.then((r) => {
|
|
464
|
+
const jobStatus = backgroundJobRegistry.get(jobId)?.status;
|
|
465
|
+
if (jobStatus !== "running" && jobStatus !== "cancelling") {
|
|
466
|
+
safeFinalizeDriveWorktree(worktree);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const cancelling = jobStatus === "cancelling";
|
|
470
|
+
// Attribute external changes BEFORE publishing completion. Previously the
|
|
471
|
+
// notification event was emitted first and carried no files; changedFiles
|
|
472
|
+
// only reached the background-work registry/panel, so the chat turn card
|
|
473
|
+
// could never count DriveAgent edits.
|
|
474
|
+
const rawChangedFiles = r.sessionId ? readChangedFiles(cli, cwd, r.sessionId) : [];
|
|
475
|
+
const changedFiles = normalizeChangedFiles(cwd, rawChangedFiles);
|
|
476
|
+
const lifecycle = safeFinalizeDriveWorktree(worktree);
|
|
477
|
+
const finalText = appendLifecycleNote(r.finalText, lifecycle);
|
|
478
|
+
if (lifecycle) {
|
|
479
|
+
backgroundJobRegistry.recordWorktreeLifecycle(jobId, lifecycle.action);
|
|
480
|
+
}
|
|
481
|
+
recordSuccessfulSession(sessionStore, cli, cwd, r, { codeShellSessionId: sessionId, workspaceRoot, isolation, worktree, lifecycle }, cancelling);
|
|
482
|
+
if (changedFiles.length > 0) {
|
|
483
|
+
recordExternalFileChanges?.({
|
|
484
|
+
jobId,
|
|
485
|
+
description: label,
|
|
486
|
+
cli,
|
|
487
|
+
cwd,
|
|
488
|
+
status: cancelling ? "cancelled" : r.isError ? "failed" : "completed",
|
|
489
|
+
changedFiles,
|
|
490
|
+
...(originClientMessageId ? { originClientMessageId } : {}),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
logger.debug("changed_files.drive_completion", {
|
|
494
|
+
cat: "changed_files",
|
|
495
|
+
jobId,
|
|
496
|
+
sessionId,
|
|
497
|
+
externalSessionId: r.sessionId || undefined,
|
|
498
|
+
cli,
|
|
499
|
+
cwd,
|
|
500
|
+
originClientMessageId,
|
|
501
|
+
rawSize: rawChangedFiles.length,
|
|
502
|
+
size: changedFiles.length,
|
|
503
|
+
files: changedFiles,
|
|
504
|
+
});
|
|
505
|
+
if (cancelling) {
|
|
506
|
+
backgroundJobRegistry.recordArtifacts(jobId, {
|
|
507
|
+
ccSessionId: r.sessionId || undefined,
|
|
508
|
+
changedFiles,
|
|
509
|
+
});
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
// Deliver the result back so the woken agent actually sees the answer
|
|
513
|
+
// (not just "a job finished"). Mirrors the video/sub-agent completion
|
|
514
|
+
// path — enqueue lands in the same notificationQueue the wakeup drains.
|
|
515
|
+
notificationQueue.enqueue(r.isError
|
|
516
|
+
? {
|
|
517
|
+
agentId: jobId,
|
|
518
|
+
description: label,
|
|
519
|
+
status: "failed",
|
|
520
|
+
workKind: "cc",
|
|
521
|
+
error: finalText || "(no output)",
|
|
522
|
+
ccSessionId: r.sessionId || undefined,
|
|
523
|
+
...(changedFiles.length ? { changedFiles, cwd } : {}),
|
|
524
|
+
...(originClientMessageId ? { originClientMessageId } : {}),
|
|
525
|
+
enqueuedAt: Date.now(),
|
|
526
|
+
}
|
|
527
|
+
: {
|
|
528
|
+
agentId: jobId,
|
|
529
|
+
description: label,
|
|
530
|
+
status: "completed",
|
|
531
|
+
workKind: "cc",
|
|
532
|
+
finalText,
|
|
533
|
+
ccSessionId: r.sessionId || undefined,
|
|
534
|
+
...(changedFiles.length ? { changedFiles, cwd } : {}),
|
|
535
|
+
...(originClientMessageId ? { originClientMessageId } : {}),
|
|
536
|
+
enqueuedAt: Date.now(),
|
|
537
|
+
}, sessionId);
|
|
538
|
+
// Retain the job in the panel with its result + the external CLI
|
|
539
|
+
// session id + changed files.
|
|
540
|
+
backgroundJobRegistry.finish(jobId, {
|
|
541
|
+
status: r.isError ? "failed" : "completed",
|
|
542
|
+
finalText: finalText || undefined,
|
|
543
|
+
ccSessionId: r.sessionId || undefined,
|
|
544
|
+
...(changedFiles.length ? { changedFiles } : {}),
|
|
545
|
+
...(lifecycle ? { worktreeLifecycle: lifecycle.action } : {}),
|
|
546
|
+
});
|
|
547
|
+
})
|
|
548
|
+
.catch((err) => {
|
|
549
|
+
if (backgroundJobRegistry.get(jobId)?.status !== "running") {
|
|
550
|
+
safeFinalizeDriveWorktree(worktree);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const lifecycle = safeFinalizeDriveWorktree(worktree);
|
|
554
|
+
const msg = appendLifecycleNote(err?.message ?? String(err), lifecycle);
|
|
555
|
+
if (lifecycle) {
|
|
556
|
+
backgroundJobRegistry.recordWorktreeLifecycle(jobId, lifecycle.action);
|
|
557
|
+
}
|
|
558
|
+
notificationQueue.enqueue({
|
|
559
|
+
agentId: jobId,
|
|
560
|
+
description: label,
|
|
561
|
+
status: "failed",
|
|
562
|
+
workKind: "cc",
|
|
563
|
+
error: msg,
|
|
564
|
+
enqueuedAt: Date.now(),
|
|
565
|
+
}, sessionId);
|
|
566
|
+
backgroundJobRegistry.finish(jobId, {
|
|
567
|
+
status: "failed",
|
|
568
|
+
finalText: msg,
|
|
569
|
+
...(lifecycle ? { worktreeLifecycle: lifecycle.action } : {}),
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
function trackBackgroundRun(params) {
|
|
574
|
+
const warning = duplicateCwdWarning(params.effectiveWorkspaceCwd, params.writable);
|
|
575
|
+
const jobId = newDriveJobId();
|
|
576
|
+
const run = params.start();
|
|
577
|
+
backgroundJobRegistry.start(jobId, params.sessionId, params.label, {
|
|
578
|
+
kind: "drive-agent",
|
|
579
|
+
launchCwd: params.cwd,
|
|
580
|
+
effectiveWorkspaceCwd: params.effectiveWorkspaceCwd,
|
|
581
|
+
workspaceRoot: params.workspaceRoot,
|
|
582
|
+
isolation: params.isolation,
|
|
583
|
+
...(params.worktree
|
|
584
|
+
? {
|
|
585
|
+
worktreePath: params.worktree.session.worktreePath,
|
|
586
|
+
worktreeBranch: params.worktree.session.worktreeBranch,
|
|
587
|
+
worktreeBaseRef: params.worktree.session.baseRef,
|
|
588
|
+
worktreeCleanup: params.worktree.cleanup,
|
|
589
|
+
worktreeBranchPrefix: params.worktree.branchPrefix,
|
|
590
|
+
}
|
|
591
|
+
: {}),
|
|
592
|
+
cli: params.cli,
|
|
593
|
+
promptSummary: params.promptSummary,
|
|
594
|
+
originClientMessageId: params.originClientMessageId,
|
|
595
|
+
abort: async () => {
|
|
596
|
+
params.abort();
|
|
597
|
+
await run.catch(() => undefined);
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
attachDriveCompletion({ ...params, jobId, run });
|
|
601
|
+
return { jobId, ...(warning ? { warning } : {}) };
|
|
602
|
+
}
|
|
603
|
+
async function waitForForegroundOrHandoff(run, handoffMs) {
|
|
604
|
+
if (handoffMs < 0) {
|
|
605
|
+
return { kind: "completed", result: await run };
|
|
606
|
+
}
|
|
607
|
+
let timer;
|
|
608
|
+
try {
|
|
609
|
+
return await Promise.race([
|
|
610
|
+
run.then((result) => ({ kind: "completed", result })),
|
|
611
|
+
new Promise((resolve) => {
|
|
612
|
+
timer = setTimeout(() => resolve({ kind: "handoff" }), handoffMs);
|
|
613
|
+
}),
|
|
614
|
+
]);
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
if (timer)
|
|
618
|
+
clearTimeout(timer);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function makeAbortController(parent, linkParent = false) {
|
|
622
|
+
const controller = new AbortController();
|
|
623
|
+
if (!parent)
|
|
624
|
+
return controller;
|
|
625
|
+
if (parent.aborted) {
|
|
626
|
+
controller.abort(parent.reason);
|
|
627
|
+
}
|
|
628
|
+
else if (linkParent) {
|
|
629
|
+
parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
|
|
630
|
+
}
|
|
631
|
+
return controller;
|
|
632
|
+
}
|
|
633
|
+
/** Factory so tests can inject a fake runner. `fixedCli` (back-compat) forces a
|
|
634
|
+
* cli and hides the `cli` arg — that's how DriveClaudeCode stays a thin alias. */
|
|
635
|
+
export function makeDriveAgentTool(runner = defaultRunner, fixedCli, options = {}) {
|
|
636
|
+
return async (args, ctx) => {
|
|
637
|
+
const prompt = typeof args.prompt === "string" ? args.prompt : "";
|
|
638
|
+
if (!prompt)
|
|
639
|
+
return "Error: prompt is required";
|
|
640
|
+
const cli = fixedCli ??
|
|
641
|
+
(args.cli === "codex"
|
|
642
|
+
? "codex"
|
|
643
|
+
: args.cli === "claude" || args.cli === undefined
|
|
644
|
+
? "claude"
|
|
645
|
+
: "invalid");
|
|
646
|
+
if (cli === "invalid")
|
|
647
|
+
return `Error: unknown cli "${String(args.cli)}" (expected "claude" or "codex")`;
|
|
648
|
+
const resumeSessionId = typeof args.resumeSessionId === "string" ? args.resumeSessionId : undefined;
|
|
649
|
+
const model = typeof args.model === "string" && args.model.trim() ? args.model : undefined;
|
|
650
|
+
const permissionMode = args.permissionMode === "default" ||
|
|
651
|
+
args.permissionMode === "acceptEdits" ||
|
|
652
|
+
args.permissionMode === "bypassPermissions"
|
|
653
|
+
? args.permissionMode
|
|
654
|
+
: "bypassPermissions";
|
|
655
|
+
const isWritableRun = permissionMode !== "default";
|
|
656
|
+
const background = args.background !== false;
|
|
657
|
+
const cliName = cli === "codex" ? "Codex" : "Claude Code";
|
|
658
|
+
if (background && !isValidSessionId(ctx?.sessionId)) {
|
|
659
|
+
return `Error: cannot start a background ${cliName} job without a session — its result notification would be dropped. Retry with background:false, or ensure the tool runs inside a session.`;
|
|
660
|
+
}
|
|
661
|
+
const callerSignal = ctx?.signal ?? argSignal(args);
|
|
662
|
+
const rawRequestedCwd = typeof args.cwd === "string" && args.cwd.trim()
|
|
663
|
+
? args.cwd
|
|
664
|
+
: typeof ctx?.cwd === "string" && ctx.cwd
|
|
665
|
+
? ctx.cwd
|
|
666
|
+
: process.cwd();
|
|
667
|
+
const requestedCwd = normalizeCwdPath(rawRequestedCwd);
|
|
668
|
+
const requestedWorkspaceRoot = await resolveDriveWorkspaceRoot(requestedCwd);
|
|
669
|
+
const requestedIsolation = driveIsolationArg(args.isolation);
|
|
670
|
+
const hasParallelWriter = !resumeSessionId &&
|
|
671
|
+
isWritableRun &&
|
|
672
|
+
hasRunningDriveWriter(requestedCwd, requestedWorkspaceRoot);
|
|
673
|
+
let isolation = requestedIsolation ?? (hasParallelWriter ? "worktree" : "none");
|
|
674
|
+
let cwd = isolation === "current" && typeof ctx?.cwd === "string" && ctx.cwd
|
|
675
|
+
? normalizeCwdPath(ctx.cwd)
|
|
676
|
+
: requestedCwd;
|
|
677
|
+
let workspaceRoot = cwd === requestedCwd ? requestedWorkspaceRoot : await resolveDriveWorkspaceRoot(cwd);
|
|
678
|
+
let managedWorktree;
|
|
679
|
+
const includeArg = driveIncludeArg(args.include);
|
|
680
|
+
if (includeArg.error)
|
|
681
|
+
return `Error: ${includeArg.error}`;
|
|
682
|
+
const cleanup = args.cleanup === "auto" ||
|
|
683
|
+
args.cleanup === "keep" ||
|
|
684
|
+
args.cleanup === "detach" ||
|
|
685
|
+
args.cleanup === "discard"
|
|
686
|
+
? args.cleanup
|
|
687
|
+
: resumeSessionId
|
|
688
|
+
? "keep"
|
|
689
|
+
: driveCleanupArg(undefined);
|
|
690
|
+
const sessionStore = options.sessionStore ?? externalAgentSessionStore;
|
|
691
|
+
let resumeNote = "";
|
|
692
|
+
if (resumeSessionId) {
|
|
693
|
+
const binding = sessionStore.get(cli, resumeSessionId);
|
|
694
|
+
if (binding) {
|
|
695
|
+
const storedCwd = normalizeCwdPath(binding.cwd);
|
|
696
|
+
if (!isExistingDirectory(storedCwd)) {
|
|
697
|
+
const branchStillExists = !!binding.worktreeBranch &&
|
|
698
|
+
!!binding.workspaceRoot &&
|
|
699
|
+
isExistingDirectory(binding.workspaceRoot) &&
|
|
700
|
+
(await branchExists(binding.workspaceRoot, binding.worktreeBranch, callerSignal));
|
|
701
|
+
return branchStillExists
|
|
702
|
+
? `Error: cannot resume ${cli} session ${resumeSessionId}: stored cwd no longer exists or is not a directory: ${storedCwd}. Branch ${binding.worktreeBranch} is preserved; recreate the worktree from ${binding.workspaceRoot} before resuming.`
|
|
703
|
+
: `Error: cannot resume ${cli} session ${resumeSessionId}: stored cwd no longer exists or is not a directory: ${storedCwd}. The bound workspace was deleted${binding.worktreeBranch ? ` and branch ${binding.worktreeBranch} is gone` : ""}; start a new external session.`;
|
|
704
|
+
}
|
|
705
|
+
isolation = binding.isolation ?? (binding.worktreePath ? "worktree" : "current");
|
|
706
|
+
workspaceRoot = binding.workspaceRoot ?? storedCwd;
|
|
707
|
+
if (storedCwd !== requestedCwd) {
|
|
708
|
+
cwd = storedCwd;
|
|
709
|
+
resumeNote = `Note: resume session ${resumeSessionId} is bound to stored cwd ${storedCwd}; ignoring requested cwd ${requestedCwd}.`;
|
|
710
|
+
logger.info("drive_agent.resume_forced_stored_cwd", {
|
|
711
|
+
cat: "cc",
|
|
712
|
+
cli,
|
|
713
|
+
sessionId: resumeSessionId,
|
|
714
|
+
requestedCwd,
|
|
715
|
+
storedCwd,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
cwd = storedCwd;
|
|
720
|
+
}
|
|
721
|
+
if (binding.worktreePath && binding.worktreeBranch) {
|
|
722
|
+
const session = {
|
|
723
|
+
originalCwd: workspaceRoot,
|
|
724
|
+
worktreePath: normalizeCwdPath(binding.worktreePath),
|
|
725
|
+
worktreeName: "drive-resume",
|
|
726
|
+
worktreeBranch: binding.worktreeBranch,
|
|
727
|
+
baseRef: binding.worktreeBaseRef,
|
|
728
|
+
sessionId: resumeSessionId,
|
|
729
|
+
createdAt: binding.createdAt,
|
|
730
|
+
};
|
|
731
|
+
try {
|
|
732
|
+
lockWorktree(session.worktreePath, `CodeShell DriveAgent ${cli} resume`);
|
|
733
|
+
}
|
|
734
|
+
catch (error) {
|
|
735
|
+
return `Error: cannot lock bound worktree ${session.worktreePath} for resume: ${error instanceof Error ? error.message : String(error)}`;
|
|
736
|
+
}
|
|
737
|
+
managedWorktree = { session, cleanup };
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
else if (requestedIsolation === "worktree") {
|
|
741
|
+
return `Error: cannot create a new worktree while resuming unbound ${cli} session ${resumeSessionId}; resume requires its original workspace binding.`;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (!resumeSessionId && isolation === "worktree") {
|
|
745
|
+
try {
|
|
746
|
+
managedWorktree = await prepareDriveWorktree({
|
|
747
|
+
cwd,
|
|
748
|
+
cli,
|
|
749
|
+
baseRef: typeof args.baseRef === "string" && args.baseRef.trim() ? args.baseRef : undefined,
|
|
750
|
+
include: includeArg.patterns,
|
|
751
|
+
cleanup,
|
|
752
|
+
ctx,
|
|
753
|
+
signal: callerSignal,
|
|
754
|
+
});
|
|
755
|
+
cwd = managedWorktree.session.worktreePath;
|
|
756
|
+
workspaceRoot = managedWorktree.session.originalCwd;
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
if (requestedIsolation === "worktree") {
|
|
760
|
+
return `Error: failed to create DriveAgent isolation worktree: ${error instanceof Error ? error.message : String(error)}`;
|
|
761
|
+
}
|
|
762
|
+
isolation = "none";
|
|
763
|
+
cwd = requestedCwd;
|
|
764
|
+
workspaceRoot = requestedWorkspaceRoot;
|
|
765
|
+
resumeNote =
|
|
766
|
+
`Note: parallel worktree isolation was unavailable; continuing in ${requestedCwd}. ` +
|
|
767
|
+
`${error instanceof Error ? error.message : String(error)}`;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const effectiveWorkspaceCwd = managedWorktree
|
|
771
|
+
? managedWorktree.session.worktreePath
|
|
772
|
+
: typeof args.effectiveWorkspaceCwd === "string" && args.effectiveWorkspaceCwd.trim()
|
|
773
|
+
? normalizeCwdPath(args.effectiveWorkspaceCwd)
|
|
774
|
+
: cwd;
|
|
775
|
+
// Default to bypassPermissions: this tool is a fire-one-turn delegation to
|
|
776
|
+
// an external CLI with nobody watching for approvals, and there is no
|
|
777
|
+
// interactive approval loop here — so under "default" a tool that needs
|
|
778
|
+
// approval (WebSearch/WebFetch/Write) silently can't run (the
|
|
779
|
+
// "DriveClaudeCode 没有联网能力" report). A caller that wants gating passes
|
|
780
|
+
// an explicit mode, which is honored. (For codex, the mode maps to a sandbox
|
|
781
|
+
// tier inside codexAdapter.)
|
|
782
|
+
const attachmentSourceCwd = managedWorktree?.session.originalCwd ?? cwd;
|
|
783
|
+
const resolvedAttachmentPaths = resolveAttachmentPaths(args.attachmentPaths, attachmentSourceCwd);
|
|
784
|
+
if (resolvedAttachmentPaths.error) {
|
|
785
|
+
safeFinalizeDriveWorktree(managedWorktree);
|
|
786
|
+
return `Error: ${resolvedAttachmentPaths.error}`;
|
|
787
|
+
}
|
|
788
|
+
const attachmentPaths = resolvedAttachmentPaths.paths;
|
|
789
|
+
const promptWithAttachments = appendAttachmentPrompt(prompt, attachmentPaths);
|
|
790
|
+
const imagePaths = cli === "codex"
|
|
791
|
+
? attachmentPaths.filter((path) => DRIVE_IMAGE_EXTS.has(extname(path).toLowerCase()))
|
|
792
|
+
: [];
|
|
793
|
+
const label = `DriveAgent(${cli}): ${prompt.slice(0, 40)}`;
|
|
794
|
+
const promptSummary = summarizePrompt(prompt);
|
|
795
|
+
const runOptsBase = {
|
|
796
|
+
cli,
|
|
797
|
+
prompt: promptWithAttachments,
|
|
798
|
+
resumeSessionId,
|
|
799
|
+
model,
|
|
800
|
+
cwd,
|
|
801
|
+
permissionMode,
|
|
802
|
+
imagePaths,
|
|
803
|
+
};
|
|
804
|
+
const foregroundHandoffMs = options.foregroundHandoffMs ?? DRIVE_AGENT_FOREGROUND_HANDOFF_MS;
|
|
805
|
+
if (background) {
|
|
806
|
+
// Fail loud on a missing sessionId: a background job whose completion
|
|
807
|
+
// notification can't be routed (enqueue drops invalid/empty sessionId)
|
|
808
|
+
// would run to completion and then silently vanish — nobody gets woken
|
|
809
|
+
// with the result. Refuse up front instead of launching disappearing work.
|
|
810
|
+
const sessionId = ctx.sessionId;
|
|
811
|
+
const abortController = makeAbortController(callerSignal, false);
|
|
812
|
+
const tracked = trackBackgroundRun({
|
|
813
|
+
sessionId,
|
|
814
|
+
label,
|
|
815
|
+
cli,
|
|
816
|
+
cwd,
|
|
817
|
+
effectiveWorkspaceCwd,
|
|
818
|
+
workspaceRoot,
|
|
819
|
+
isolation,
|
|
820
|
+
worktree: managedWorktree,
|
|
821
|
+
promptSummary,
|
|
822
|
+
start: () => startRun(runner, { ...runOptsBase, signal: abortController.signal }),
|
|
823
|
+
abort: () => abortController.abort(),
|
|
824
|
+
sessionStore,
|
|
825
|
+
writable: isWritableRun,
|
|
826
|
+
readChangedFiles: options.readChangedFiles ?? readExternalChangedFiles,
|
|
827
|
+
recordExternalFileChanges: ctx?.recordExternalFileChanges,
|
|
828
|
+
originClientMessageId: ctx?.originClientMessageId,
|
|
829
|
+
});
|
|
830
|
+
ctx?.runYield?.request("background_notification");
|
|
831
|
+
return [
|
|
832
|
+
resumeNote,
|
|
833
|
+
`已在后台启动 ${cliName}(jobId ${tracked.jobId})。完成后会通知你结果,无需轮询。`,
|
|
834
|
+
worktreeStartNote(managedWorktree),
|
|
835
|
+
tracked.warning,
|
|
836
|
+
]
|
|
837
|
+
.filter(Boolean)
|
|
838
|
+
.join("\n");
|
|
839
|
+
}
|
|
840
|
+
const foregroundAbort = makeAbortController(callerSignal, true);
|
|
841
|
+
const run = startRun(runner, { ...runOptsBase, signal: foregroundAbort.signal });
|
|
842
|
+
let result;
|
|
843
|
+
try {
|
|
844
|
+
result = await waitForForegroundOrHandoff(run, foregroundHandoffMs);
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
const lifecycle = safeFinalizeDriveWorktree(managedWorktree);
|
|
848
|
+
return appendLifecycleNote(`${cliName} 运行出错:${error instanceof Error ? error.message : String(error)}`, lifecycle);
|
|
849
|
+
}
|
|
850
|
+
if (result.kind === "handoff" && isValidSessionId(ctx?.sessionId)) {
|
|
851
|
+
const tracked = trackBackgroundRun({
|
|
852
|
+
sessionId: ctx.sessionId,
|
|
853
|
+
label,
|
|
854
|
+
cli,
|
|
855
|
+
cwd,
|
|
856
|
+
effectiveWorkspaceCwd,
|
|
857
|
+
workspaceRoot,
|
|
858
|
+
isolation,
|
|
859
|
+
worktree: managedWorktree,
|
|
860
|
+
promptSummary,
|
|
861
|
+
start: () => run,
|
|
862
|
+
abort: () => foregroundAbort.abort(),
|
|
863
|
+
sessionStore,
|
|
864
|
+
writable: isWritableRun,
|
|
865
|
+
readChangedFiles: options.readChangedFiles ?? readExternalChangedFiles,
|
|
866
|
+
recordExternalFileChanges: ctx?.recordExternalFileChanges,
|
|
867
|
+
originClientMessageId: ctx?.originClientMessageId,
|
|
868
|
+
});
|
|
869
|
+
ctx?.runYield?.request("background_notification");
|
|
870
|
+
return [
|
|
871
|
+
resumeNote,
|
|
872
|
+
`${cliName} foreground run exceeded ${foregroundHandoffMs}ms; moved it to background (jobId ${tracked.jobId}). Completion will notify this session, so do not poll.`,
|
|
873
|
+
worktreeStartNote(managedWorktree),
|
|
874
|
+
tracked.warning,
|
|
875
|
+
]
|
|
876
|
+
.filter(Boolean)
|
|
877
|
+
.join("\n");
|
|
878
|
+
}
|
|
879
|
+
const r = result.kind === "completed" ? result.result : await run;
|
|
880
|
+
const lifecycle = safeFinalizeDriveWorktree(managedWorktree);
|
|
881
|
+
recordSuccessfulSession(sessionStore, cli, cwd, r, {
|
|
882
|
+
codeShellSessionId: ctx?.sessionId,
|
|
883
|
+
workspaceRoot,
|
|
884
|
+
isolation,
|
|
885
|
+
worktree: managedWorktree,
|
|
886
|
+
lifecycle,
|
|
887
|
+
});
|
|
888
|
+
const prefix = resumeNote ? `${resumeNote}\n` : "";
|
|
889
|
+
const finalText = appendLifecycleNote(r.finalText, lifecycle);
|
|
890
|
+
if (r.isError)
|
|
891
|
+
return `${prefix}${cliName} 运行出错(session ${r.sessionId}):\n${finalText}`;
|
|
892
|
+
return `${prefix}${cliName} 完成(session ${r.sessionId}):\n${finalText}`;
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
export const driveAgentTool = makeDriveAgentTool();
|
|
896
|
+
export const driveAgentJobsToolDef = {
|
|
897
|
+
name: "DriveAgentJobs",
|
|
898
|
+
description: "List, inspect, cancel, or clean up background DriveAgent jobs. action:'list' without cwd defaults to " +
|
|
899
|
+
"the current CodeShell session, not by cwd; all:true expands that to every session. status " +
|
|
900
|
+
"defaults to running, so use " +
|
|
901
|
+
"status:'all' to include completed, failed, and cancelled retained jobs. Before launching " +
|
|
902
|
+
"writable DriveAgent work, pass cwd as a cross-session conflict filter; cwd is not a grouping " +
|
|
903
|
+
"or ownership dimension. Results include prompt summary, owner session, launchCwd, CLI kind, " +
|
|
904
|
+
"status, start time, and known changed files. With status:'all', list groups active jobs before " +
|
|
905
|
+
"terminal jobs and includes each terminal job's " +
|
|
906
|
+
"available/non-empty finalText result summary, so you usually do not need to inspect every " +
|
|
907
|
+
"completed job. Use " +
|
|
908
|
+
"resultChars to control each result summary (default 800; 0 or less hides results). " +
|
|
909
|
+
"Use action:'inspect' with jobId for full details, action:'cancel' to abort a running process, " +
|
|
910
|
+
"or action:'cleanup' with cleanup:'detach'/'discard' for a retained isolated worktree.",
|
|
911
|
+
inputSchema: {
|
|
912
|
+
type: "object",
|
|
913
|
+
properties: {
|
|
914
|
+
action: {
|
|
915
|
+
type: "string",
|
|
916
|
+
enum: ["list", "inspect", "cancel", "cleanup"],
|
|
917
|
+
description: "What to do. Defaults to list.",
|
|
918
|
+
},
|
|
919
|
+
jobId: {
|
|
920
|
+
type: "string",
|
|
921
|
+
description: "DriveAgent jobId for inspect or cancel.",
|
|
922
|
+
},
|
|
923
|
+
cwd: {
|
|
924
|
+
type: "string",
|
|
925
|
+
description: "When listing, use the effective workspace containing this cwd as a cross-session filter for conflict inspection. A job's launch cwd is output metadata, not a grouping or ownership key; omit cwd to list the current CodeShell session.",
|
|
926
|
+
},
|
|
927
|
+
status: {
|
|
928
|
+
type: "string",
|
|
929
|
+
enum: ["running", "all"],
|
|
930
|
+
description: "When listing: running (default) or all retained DriveAgent jobs.",
|
|
931
|
+
},
|
|
932
|
+
all: {
|
|
933
|
+
type: "boolean",
|
|
934
|
+
description: "When listing without cwd: true lists DriveAgent jobs from every session; default lists only this session when session context exists.",
|
|
935
|
+
},
|
|
936
|
+
resultChars: {
|
|
937
|
+
type: "number",
|
|
938
|
+
default: DEFAULT_DRIVE_JOB_RESULT_CHARS,
|
|
939
|
+
description: "When listing, maximum characters of finalText shown for each terminal job. Defaults to 800; 0 or a negative number hides results and returns summary-only job entries. Use inspect for the full result.",
|
|
940
|
+
},
|
|
941
|
+
cleanup: {
|
|
942
|
+
type: "string",
|
|
943
|
+
enum: ["keep", "detach", "discard"],
|
|
944
|
+
description: "With action cleanup: keep unlocks/preserves; detach removes the directory only when there are no uncommitted changes; discard removes directory and managed branch.",
|
|
945
|
+
},
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
};
|
|
949
|
+
function jobIdArg(args) {
|
|
950
|
+
const camel = typeof args.jobId === "string" ? args.jobId.trim() : "";
|
|
951
|
+
if (camel)
|
|
952
|
+
return camel;
|
|
953
|
+
const snake = typeof args.job_id === "string" ? args.job_id.trim() : "";
|
|
954
|
+
return snake || undefined;
|
|
955
|
+
}
|
|
956
|
+
function listDriveAgentJobs(args, ctx) {
|
|
957
|
+
const rawCwd = typeof args.cwd === "string" && args.cwd.trim() ? args.cwd : undefined;
|
|
958
|
+
const cwd = rawCwd ? normalizeCwdPath(rawCwd) : undefined;
|
|
959
|
+
const sessionId = ctx?.sessionId;
|
|
960
|
+
const listAllSessions = args.all === true || !!cwd || !sessionId;
|
|
961
|
+
const status = args.status === "all" ? "all" : "running";
|
|
962
|
+
let jobs = cwd
|
|
963
|
+
? backgroundJobRegistry.listByWorkspaceCwd(cwd)
|
|
964
|
+
: listAllSessions || !sessionId
|
|
965
|
+
? backgroundJobRegistry.list()
|
|
966
|
+
: backgroundJobRegistry.listForSession(sessionId);
|
|
967
|
+
jobs = jobs.filter(isDriveAgentJob);
|
|
968
|
+
if (status === "running") {
|
|
969
|
+
jobs = jobs.filter((job) => job.status === "running" || job.status === "cancelling");
|
|
970
|
+
}
|
|
971
|
+
if (jobs.length === 0) {
|
|
972
|
+
if (cwd)
|
|
973
|
+
return `No ${status} DriveAgent jobs in the effective workspace containing ${cwd}.`;
|
|
974
|
+
if (listAllSessions)
|
|
975
|
+
return `No ${status} DriveAgent jobs in this process.`;
|
|
976
|
+
return `No ${status} DriveAgent jobs in this session.`;
|
|
977
|
+
}
|
|
978
|
+
const resultChars = driveJobResultChars(args.resultChars);
|
|
979
|
+
if (resultChars <= 0)
|
|
980
|
+
return jobs.map(formatDriveJobListLine).join("\n");
|
|
981
|
+
const activeJobs = jobs.filter((job) => !isTerminalDriveJob(job));
|
|
982
|
+
const terminalJobs = jobs.filter(isTerminalDriveJob);
|
|
983
|
+
const activeOutput = activeJobs.map(formatDriveJobListLine).join("\n");
|
|
984
|
+
const terminalOutput = terminalJobs
|
|
985
|
+
.map((job) => formatDriveJobListBlock(job, resultChars))
|
|
986
|
+
.join("\n\n");
|
|
987
|
+
if (activeOutput && terminalOutput) {
|
|
988
|
+
return [
|
|
989
|
+
`Active DriveAgent jobs:\n${activeOutput}`,
|
|
990
|
+
`Terminal DriveAgent jobs:\n${terminalOutput}`,
|
|
991
|
+
].join("\n\n");
|
|
992
|
+
}
|
|
993
|
+
return activeOutput || terminalOutput;
|
|
994
|
+
}
|
|
995
|
+
function inspectDriveAgentJob(jobId) {
|
|
996
|
+
if (!jobId)
|
|
997
|
+
return "Error: jobId is required.";
|
|
998
|
+
const job = backgroundJobRegistry.get(jobId);
|
|
999
|
+
if (!job || !isDriveAgentJob(job))
|
|
1000
|
+
return `Error: DriveAgent jobId "${jobId}" not found.`;
|
|
1001
|
+
const lines = [
|
|
1002
|
+
`jobId: ${job.jobId}`,
|
|
1003
|
+
`status: ${job.status}`,
|
|
1004
|
+
`cli: ${job.cli ?? "unknown"}`,
|
|
1005
|
+
`session: ${job.sessionId}`,
|
|
1006
|
+
`launchCwd: ${job.launchCwd ?? job.cwd ?? "(unknown cwd)"}`,
|
|
1007
|
+
`startedAt: ${new Date(job.startedAt).toISOString()}`,
|
|
1008
|
+
`duration: ${jobDurationSeconds(job)}`,
|
|
1009
|
+
`prompt: ${job.promptSummary || job.description || "(no prompt summary)"}`,
|
|
1010
|
+
`description: ${job.description}`,
|
|
1011
|
+
];
|
|
1012
|
+
if (job.isolation)
|
|
1013
|
+
lines.push(`isolation: ${job.isolation}`);
|
|
1014
|
+
if (job.worktreePath)
|
|
1015
|
+
lines.push(`worktreePath: ${job.worktreePath}`);
|
|
1016
|
+
if (job.worktreeBranch)
|
|
1017
|
+
lines.push(`worktreeBranch: ${job.worktreeBranch}`);
|
|
1018
|
+
if (job.worktreeBaseRef)
|
|
1019
|
+
lines.push(`worktreeBaseRef: ${job.worktreeBaseRef}`);
|
|
1020
|
+
if (job.worktreeCleanup)
|
|
1021
|
+
lines.push(`worktreeCleanup: ${job.worktreeCleanup}`);
|
|
1022
|
+
if (job.worktreeLifecycle)
|
|
1023
|
+
lines.push(`worktreeLifecycle: ${job.worktreeLifecycle}`);
|
|
1024
|
+
if (job.finishedAt !== undefined)
|
|
1025
|
+
lines.push(`finishedAt: ${new Date(job.finishedAt).toISOString()}`);
|
|
1026
|
+
if (job.ccSessionId)
|
|
1027
|
+
lines.push(`ccSessionId: ${job.ccSessionId}`);
|
|
1028
|
+
if (job.changedFiles && job.changedFiles.length > 0) {
|
|
1029
|
+
lines.push("changedFiles:", ...job.changedFiles.map((file) => `- ${file}`));
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
lines.push("changedFiles: unknown");
|
|
1033
|
+
}
|
|
1034
|
+
if (job.finalText)
|
|
1035
|
+
lines.push("finalText:", job.finalText);
|
|
1036
|
+
return lines.join("\n");
|
|
1037
|
+
}
|
|
1038
|
+
async function cancelDriveAgentJob(jobId) {
|
|
1039
|
+
if (!jobId)
|
|
1040
|
+
return "Error: jobId is required.";
|
|
1041
|
+
const job = backgroundJobRegistry.get(jobId);
|
|
1042
|
+
if (!job || !isDriveAgentJob(job))
|
|
1043
|
+
return `Error: DriveAgent jobId "${jobId}" not found.`;
|
|
1044
|
+
if (job.status !== "running") {
|
|
1045
|
+
return `DriveAgent job ${jobId} is already ${job.status}; nothing to cancel.`;
|
|
1046
|
+
}
|
|
1047
|
+
if (!job.abort) {
|
|
1048
|
+
return `Error: DriveAgent job ${jobId} has no cancellation handle recorded.`;
|
|
1049
|
+
}
|
|
1050
|
+
const finalText = `DriveAgent job ${jobId} cancelled by DriveAgentJobs.`;
|
|
1051
|
+
const ok = await backgroundJobRegistry.cancel(jobId, { finalText });
|
|
1052
|
+
if (!ok)
|
|
1053
|
+
return `Failed to cancel DriveAgent job ${jobId}.`;
|
|
1054
|
+
notificationQueue.enqueue({
|
|
1055
|
+
agentId: jobId,
|
|
1056
|
+
description: job.description,
|
|
1057
|
+
status: "cancelled",
|
|
1058
|
+
workKind: "cc",
|
|
1059
|
+
error: finalText,
|
|
1060
|
+
ccSessionId: job.ccSessionId,
|
|
1061
|
+
...(job.changedFiles?.length
|
|
1062
|
+
? { changedFiles: job.changedFiles, cwd: job.launchCwd ?? job.cwd }
|
|
1063
|
+
: {}),
|
|
1064
|
+
...(job.originClientMessageId ? { originClientMessageId: job.originClientMessageId } : {}),
|
|
1065
|
+
enqueuedAt: Date.now(),
|
|
1066
|
+
}, job.sessionId);
|
|
1067
|
+
return `DriveAgent job ${jobId} cancelled.`;
|
|
1068
|
+
}
|
|
1069
|
+
function cleanupDriveAgentWorktree(jobId, requested) {
|
|
1070
|
+
if (!jobId)
|
|
1071
|
+
return "Error: jobId is required.";
|
|
1072
|
+
const job = backgroundJobRegistry.get(jobId);
|
|
1073
|
+
if (!job || !isDriveAgentJob(job))
|
|
1074
|
+
return `Error: DriveAgent jobId "${jobId}" not found.`;
|
|
1075
|
+
if (job.status === "running" || job.status === "cancelling") {
|
|
1076
|
+
return `Error: DriveAgent job ${jobId} is still ${job.status}; cancel or wait before cleanup.`;
|
|
1077
|
+
}
|
|
1078
|
+
if (!job.worktreePath || !job.worktreeBranch) {
|
|
1079
|
+
return `Error: DriveAgent job ${jobId} has no managed worktree.`;
|
|
1080
|
+
}
|
|
1081
|
+
const action = requested === "keep" || requested === "detach" || requested === "discard"
|
|
1082
|
+
? requested
|
|
1083
|
+
: undefined;
|
|
1084
|
+
if (!action)
|
|
1085
|
+
return "Error: cleanup must be keep, detach, or discard.";
|
|
1086
|
+
if (!existsSync(job.worktreePath)) {
|
|
1087
|
+
return `Worktree directory is already absent: ${job.worktreePath} (lifecycle ${job.worktreeLifecycle ?? "unknown"}).`;
|
|
1088
|
+
}
|
|
1089
|
+
const active = backgroundJobRegistry
|
|
1090
|
+
.listRunningByCwd(job.worktreePath)
|
|
1091
|
+
.filter((entry) => entry.jobId !== jobId && isDriveAgentJob(entry));
|
|
1092
|
+
if (active.length > 0) {
|
|
1093
|
+
return `Error: worktree ${job.worktreePath} is in use by running DriveAgent job(s): ${active.map((entry) => entry.jobId).join(", ")}.`;
|
|
1094
|
+
}
|
|
1095
|
+
try {
|
|
1096
|
+
if (action === "keep") {
|
|
1097
|
+
unlockWorktree(job.worktreePath);
|
|
1098
|
+
const note = `Worktree kept at ${job.worktreePath}; branch ${job.worktreeBranch} preserved.`;
|
|
1099
|
+
backgroundJobRegistry.recordWorktreeLifecycle(jobId, "kept", note);
|
|
1100
|
+
return note;
|
|
1101
|
+
}
|
|
1102
|
+
const state = inspectWorktreeChanges(job.worktreePath, job.worktreeBaseRef);
|
|
1103
|
+
if (action === "detach" && state.uncommitted) {
|
|
1104
|
+
return `Error: detach refused because ${job.worktreePath} has uncommitted changes. Use keep or explicit discard.`;
|
|
1105
|
+
}
|
|
1106
|
+
unlockWorktree(job.worktreePath);
|
|
1107
|
+
const removal = removeWorktree(job.worktreePath, action === "discard", {
|
|
1108
|
+
prefix: job.worktreeBranchPrefix,
|
|
1109
|
+
});
|
|
1110
|
+
if (action === "discard" && removal.branchDeleted === false) {
|
|
1111
|
+
const note = `Worktree directory removed; branch ${removal.branch ?? job.worktreeBranch} preserved because ` +
|
|
1112
|
+
`deletion failed: ${removal.branchError ?? "unknown error"}.`;
|
|
1113
|
+
backgroundJobRegistry.recordWorktreeLifecycle(jobId, "cleanup-failed", note);
|
|
1114
|
+
return `Warning: ${note}`;
|
|
1115
|
+
}
|
|
1116
|
+
const lifecycle = action === "discard" ? "discarded" : "detached";
|
|
1117
|
+
const note = action === "discard"
|
|
1118
|
+
? `Worktree ${job.worktreePath} and branch ${job.worktreeBranch} deleted.`
|
|
1119
|
+
: `Worktree directory ${job.worktreePath} removed; branch ${job.worktreeBranch} preserved.`;
|
|
1120
|
+
backgroundJobRegistry.recordWorktreeLifecycle(jobId, lifecycle, note);
|
|
1121
|
+
return note;
|
|
1122
|
+
}
|
|
1123
|
+
catch (error) {
|
|
1124
|
+
const note = `Cleanup failed for ${job.worktreePath}: ${error instanceof Error ? error.message : String(error)}.`;
|
|
1125
|
+
backgroundJobRegistry.recordWorktreeLifecycle(jobId, "cleanup-failed", note);
|
|
1126
|
+
return `Error: ${note}`;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
export async function driveAgentJobsTool(args, ctx) {
|
|
1130
|
+
const action = args.action === "inspect" ||
|
|
1131
|
+
args.action === "cancel" ||
|
|
1132
|
+
args.action === "cleanup" ||
|
|
1133
|
+
args.action === "list"
|
|
1134
|
+
? args.action
|
|
1135
|
+
: "list";
|
|
1136
|
+
if (action === "inspect")
|
|
1137
|
+
return inspectDriveAgentJob(jobIdArg(args));
|
|
1138
|
+
if (action === "cancel")
|
|
1139
|
+
return await cancelDriveAgentJob(jobIdArg(args));
|
|
1140
|
+
if (action === "cleanup")
|
|
1141
|
+
return cleanupDriveAgentWorktree(jobIdArg(args), args.cleanup);
|
|
1142
|
+
return listDriveAgentJobs(args, ctx);
|
|
1143
|
+
}
|
|
1144
|
+
// ── Back-compat: DriveClaudeCode = DriveAgent pinned to cli:"claude" ──────────
|
|
1145
|
+
// Kept so old prompts / memories / call sites that reference DriveClaudeCode
|
|
1146
|
+
// keep working. It's a thin alias over the same machinery (fixedCli "claude").
|
|
1147
|
+
export const driveClaudeCodeToolDef = {
|
|
1148
|
+
...driveAgentToolDef,
|
|
1149
|
+
name: "DriveClaudeCode",
|
|
1150
|
+
description: "(Alias of DriveAgent with cli:claude.) Delegate a task to the external Claude Code CLI " +
|
|
1151
|
+
"(drives `claude` for one turn). Prefer DriveAgent for new calls; this name is kept for " +
|
|
1152
|
+
"compatibility. " +
|
|
1153
|
+
driveAgentToolDef.description,
|
|
1154
|
+
inputSchema: {
|
|
1155
|
+
type: "object",
|
|
1156
|
+
properties: {
|
|
1157
|
+
// intentionally omit `cli` — this alias is always claude
|
|
1158
|
+
prompt: driveAgentToolDef.inputSchema.properties.prompt,
|
|
1159
|
+
resumeSessionId: {
|
|
1160
|
+
type: "string",
|
|
1161
|
+
description: "Existing CC session id to resume (keeps context). Omit for a fresh session.",
|
|
1162
|
+
},
|
|
1163
|
+
model: driveAgentToolDef.inputSchema.properties.model,
|
|
1164
|
+
cwd: driveAgentToolDef.inputSchema.properties.cwd,
|
|
1165
|
+
isolation: driveAgentToolDef.inputSchema.properties.isolation,
|
|
1166
|
+
baseRef: driveAgentToolDef.inputSchema.properties.baseRef,
|
|
1167
|
+
include: driveAgentToolDef.inputSchema.properties.include,
|
|
1168
|
+
cleanup: driveAgentToolDef.inputSchema.properties.cleanup,
|
|
1169
|
+
effectiveWorkspaceCwd: driveAgentToolDef.inputSchema.properties
|
|
1170
|
+
.effectiveWorkspaceCwd,
|
|
1171
|
+
attachmentPaths: driveAgentToolDef.inputSchema.properties.attachmentPaths,
|
|
1172
|
+
permissionMode: driveAgentToolDef.inputSchema.properties.permissionMode,
|
|
1173
|
+
background: driveAgentToolDef.inputSchema.properties.background,
|
|
1174
|
+
},
|
|
1175
|
+
required: ["prompt"],
|
|
1176
|
+
},
|
|
1177
|
+
};
|
|
1178
|
+
export function makeDriveClaudeCodeTool(runner, options) {
|
|
1179
|
+
const generic = runner
|
|
1180
|
+
? ({ prompt, resumeSessionId, model, cwd, permissionMode, signal }) => runner({ prompt, resumeSessionId, model, cwd, permissionMode, signal })
|
|
1181
|
+
: undefined;
|
|
1182
|
+
return makeDriveAgentTool(generic ?? defaultRunner, "claude", options);
|
|
1183
|
+
}
|
|
1184
|
+
export const driveClaudeCodeTool = makeDriveClaudeCodeTool();
|