dsh-plugin-cc 0.1.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/.claude-plugin/marketplace.json +17 -0
- package/LICENSE +201 -0
- package/NOTICE +11 -0
- package/README.md +96 -0
- package/package.json +12 -0
- package/plugins/dsh/.claude-plugin/plugin.json +6 -0
- package/plugins/dsh/CHANGELOG.md +8 -0
- package/plugins/dsh/LICENSE +201 -0
- package/plugins/dsh/NOTICE +11 -0
- package/plugins/dsh/agents/dsh-rescue.md +41 -0
- package/plugins/dsh/commands/cancel.md +8 -0
- package/plugins/dsh/commands/plan.md +54 -0
- package/plugins/dsh/commands/rescue.md +51 -0
- package/plugins/dsh/commands/result.md +15 -0
- package/plugins/dsh/commands/review-plan.md +69 -0
- package/plugins/dsh/commands/setup.md +37 -0
- package/plugins/dsh/commands/status.md +17 -0
- package/plugins/dsh/hooks/hooks.json +27 -0
- package/plugins/dsh/prompts/plan.md +21 -0
- package/plugins/dsh/prompts/review-plan.md +25 -0
- package/plugins/dsh/scripts/dsh-companion.mjs +720 -0
- package/plugins/dsh/scripts/lib/args.mjs +130 -0
- package/plugins/dsh/scripts/lib/dsh.mjs +238 -0
- package/plugins/dsh/scripts/lib/fs.mjs +40 -0
- package/plugins/dsh/scripts/lib/git.mjs +14 -0
- package/plugins/dsh/scripts/lib/job-control.mjs +314 -0
- package/plugins/dsh/scripts/lib/plans.mjs +70 -0
- package/plugins/dsh/scripts/lib/process.mjs +156 -0
- package/plugins/dsh/scripts/lib/prompts.mjs +13 -0
- package/plugins/dsh/scripts/lib/render.mjs +231 -0
- package/plugins/dsh/scripts/lib/state.mjs +258 -0
- package/plugins/dsh/scripts/lib/tracked-jobs.mjs +242 -0
- package/plugins/dsh/scripts/lib/workspace.mjs +9 -0
- package/plugins/dsh/scripts/session-lifecycle-hook.mjs +116 -0
- package/plugins/dsh/skills/dsh-cli-runtime/SKILL.md +40 -0
- package/plugins/dsh/skills/dsh-result-handling/SKILL.md +19 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
import { parseArgs, splitRawArgumentString } from "./lib/args.mjs";
|
|
9
|
+
import {
|
|
10
|
+
CHILD_ENV,
|
|
11
|
+
DEFAULT_MODEL,
|
|
12
|
+
getDshAvailability,
|
|
13
|
+
normalizeModel,
|
|
14
|
+
runHeadless
|
|
15
|
+
} from "./lib/dsh.mjs";
|
|
16
|
+
import { readStdinIfPiped } from "./lib/fs.mjs";
|
|
17
|
+
import {
|
|
18
|
+
buildSingleJobSnapshot,
|
|
19
|
+
buildStatusSnapshot,
|
|
20
|
+
readStoredJob,
|
|
21
|
+
resolveCancelableJob,
|
|
22
|
+
resolveResultJob,
|
|
23
|
+
resolveResumableTask
|
|
24
|
+
} from "./lib/job-control.mjs";
|
|
25
|
+
import { collectPlanFiles, findLatestPlanDir } from "./lib/plans.mjs";
|
|
26
|
+
import { binaryAvailable, processCommandIncludes, terminateProcessTree } from "./lib/process.mjs";
|
|
27
|
+
import { interpolateTemplate, loadPromptTemplate } from "./lib/prompts.mjs";
|
|
28
|
+
import {
|
|
29
|
+
renderCancelReport,
|
|
30
|
+
renderJobStatusReport,
|
|
31
|
+
renderSetupReport,
|
|
32
|
+
renderStatusReport,
|
|
33
|
+
renderStoredJobResult
|
|
34
|
+
} from "./lib/render.mjs";
|
|
35
|
+
import { generateJobId, upsertJob, withStateLock, writeJobFile } from "./lib/state.mjs";
|
|
36
|
+
import {
|
|
37
|
+
appendLogLine,
|
|
38
|
+
createJobLogFile,
|
|
39
|
+
createJobProgressUpdater,
|
|
40
|
+
createJobRecord,
|
|
41
|
+
createProgressReporter,
|
|
42
|
+
nowIso,
|
|
43
|
+
recordDshPid,
|
|
44
|
+
runTrackedJob
|
|
45
|
+
} from "./lib/tracked-jobs.mjs";
|
|
46
|
+
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
|
|
47
|
+
|
|
48
|
+
const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
49
|
+
const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000;
|
|
50
|
+
const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000;
|
|
51
|
+
const SMOKE_TIMEOUT_MS = 90000;
|
|
52
|
+
const READ_ONLY = "read-only";
|
|
53
|
+
const WORKSPACE_WRITE = "workspace-write";
|
|
54
|
+
const VERDICTS = ["approve", "needs-changes", "reject"];
|
|
55
|
+
|
|
56
|
+
class UsageError extends Error {
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.exitCode = 2;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function printUsage() {
|
|
64
|
+
console.log(
|
|
65
|
+
[
|
|
66
|
+
"Usage:",
|
|
67
|
+
" node scripts/dsh-companion.mjs setup [--json]",
|
|
68
|
+
" node scripts/dsh-companion.mjs plan [--model <flash|pro|name>] <request>",
|
|
69
|
+
" node scripts/dsh-companion.mjs review-plan [--model <flash|pro|name>] <plan-path> [focus text]",
|
|
70
|
+
" node scripts/dsh-companion.mjs plan-candidates --json",
|
|
71
|
+
" node scripts/dsh-companion.mjs task [--write|--read-only] [--resume-last] [--model <flash|pro|name>] <prompt>",
|
|
72
|
+
" node scripts/dsh-companion.mjs task-resume-candidate --json",
|
|
73
|
+
" node scripts/dsh-companion.mjs status [job-id] [--all] [--wait] [--json]",
|
|
74
|
+
" node scripts/dsh-companion.mjs result [job-id] [--json]",
|
|
75
|
+
" node scripts/dsh-companion.mjs cancel [job-id] [--json]"
|
|
76
|
+
].join("\n")
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function outputResult(value, asJson) {
|
|
81
|
+
if (asJson) {
|
|
82
|
+
console.log(JSON.stringify(value, null, 2));
|
|
83
|
+
} else {
|
|
84
|
+
process.stdout.write(value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function outputCommandResult(payload, rendered, asJson) {
|
|
89
|
+
outputResult(asJson ? payload : rendered, asJson);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeArgv(argv) {
|
|
93
|
+
if (argv.length === 1) {
|
|
94
|
+
const [raw] = argv;
|
|
95
|
+
if (!raw || !raw.trim()) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
return splitRawArgumentString(raw);
|
|
99
|
+
}
|
|
100
|
+
return argv;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --wait and --background are handled by the slash commands. They are accepted and ignored here
|
|
104
|
+
// so a stray flag never turns into prompt text.
|
|
105
|
+
function parseCommandInput(argv, config = {}) {
|
|
106
|
+
return parseArgs(normalizeArgv(argv), {
|
|
107
|
+
...config,
|
|
108
|
+
booleanOptions: [...(config.booleanOptions ?? []), "background", "wait"],
|
|
109
|
+
aliasMap: {
|
|
110
|
+
C: "cwd",
|
|
111
|
+
...(config.aliasMap ?? {})
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function canonicalCwd(cwd) {
|
|
117
|
+
try {
|
|
118
|
+
return fs.realpathSync.native(cwd);
|
|
119
|
+
} catch {
|
|
120
|
+
return cwd;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveCommandCwd(options = {}) {
|
|
125
|
+
return canonicalCwd(options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sleep(ms) {
|
|
129
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function shorten(text, limit = 96) {
|
|
133
|
+
const normalized = String(text ?? "").trim().replace(/\s+/g, " ");
|
|
134
|
+
if (!normalized) {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
if (normalized.length <= limit) {
|
|
138
|
+
return normalized;
|
|
139
|
+
}
|
|
140
|
+
return `${normalized.slice(0, limit - 3)}...`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function firstMeaningfulLine(text, fallback) {
|
|
144
|
+
const line = String(text ?? "")
|
|
145
|
+
.split(/\r?\n/)
|
|
146
|
+
.map((value) => value.trim())
|
|
147
|
+
.find(Boolean);
|
|
148
|
+
return line ?? fallback;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isDshAvailable(cwd) {
|
|
152
|
+
const availability = getDshAvailability(cwd);
|
|
153
|
+
if (!availability.available) {
|
|
154
|
+
throw new Error("dsh is not installed. Run /dsh:setup.");
|
|
155
|
+
}
|
|
156
|
+
return availability;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function findOnPath(command) {
|
|
160
|
+
const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD").split(";") : [""];
|
|
161
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
162
|
+
for (const extension of extensions) {
|
|
163
|
+
const candidate = path.join(dir, `${command}${extension}`);
|
|
164
|
+
try {
|
|
165
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
166
|
+
return candidate;
|
|
167
|
+
} catch {
|
|
168
|
+
// Not here; keep looking.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Tracked runs
|
|
177
|
+
|
|
178
|
+
function createCompanionJob({ prefix, kind, title, workspaceRoot, cwd, summary, permissionMode, model }) {
|
|
179
|
+
return createJobRecord({
|
|
180
|
+
id: generateJobId(prefix),
|
|
181
|
+
kind,
|
|
182
|
+
kindLabel: kind === "task" ? "rescue" : kind,
|
|
183
|
+
title,
|
|
184
|
+
workspaceRoot,
|
|
185
|
+
cwd,
|
|
186
|
+
jobClass: kind === "task" ? "task" : "plan",
|
|
187
|
+
summary,
|
|
188
|
+
permissionMode,
|
|
189
|
+
model,
|
|
190
|
+
status: "queued"
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function phaseForEvent(event) {
|
|
195
|
+
if (event.type === "tool_call") {
|
|
196
|
+
return { phase: "investigating", message: `Running tool: ${event.tool ?? "tool"}` };
|
|
197
|
+
}
|
|
198
|
+
if (event.type === "status" && event.phase === "turn_start") {
|
|
199
|
+
return { phase: "running", message: "Turn started." };
|
|
200
|
+
}
|
|
201
|
+
if (event.type === "final") {
|
|
202
|
+
return { phase: "finalizing", message: "Final answer received." };
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Runs one dsh headless process under job tracking, in the foreground of this process.
|
|
209
|
+
* `summarize(finalText)` produces the one-line job summary.
|
|
210
|
+
*/
|
|
211
|
+
async function executeRun({ job, prompt, permissionMode, model, sessionId, summarize, asJson }) {
|
|
212
|
+
const logFile = createJobLogFile(job.workspaceRoot, job.id, job.title);
|
|
213
|
+
const jobWithLog = { ...job, logFile };
|
|
214
|
+
upsertJob(job.workspaceRoot, jobWithLog);
|
|
215
|
+
writeJobFile(job.workspaceRoot, job.id, jobWithLog);
|
|
216
|
+
|
|
217
|
+
const updateProgress = createJobProgressUpdater(job.workspaceRoot, job.id);
|
|
218
|
+
const report = createProgressReporter({ logFile, onEvent: updateProgress });
|
|
219
|
+
let dshPid = null;
|
|
220
|
+
|
|
221
|
+
const forwardSignal = (signal) => {
|
|
222
|
+
if (dshPid) {
|
|
223
|
+
try {
|
|
224
|
+
process.kill(-dshPid, signal);
|
|
225
|
+
} catch {
|
|
226
|
+
// The group is already gone.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
const onSigint = () => forwardSignal("SIGINT");
|
|
231
|
+
const onSigterm = () => forwardSignal("SIGTERM");
|
|
232
|
+
process.on("SIGINT", onSigint);
|
|
233
|
+
process.on("SIGTERM", onSigterm);
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const execution = await runTrackedJob(
|
|
237
|
+
jobWithLog,
|
|
238
|
+
async () => {
|
|
239
|
+
const result = await runHeadless({
|
|
240
|
+
cwd: job.cwd,
|
|
241
|
+
prompt,
|
|
242
|
+
permissionMode,
|
|
243
|
+
model,
|
|
244
|
+
sessionId,
|
|
245
|
+
onSpawn: (pid) => {
|
|
246
|
+
dshPid = pid;
|
|
247
|
+
if (!recordDshPid(job.workspaceRoot, job.id, pid)) {
|
|
248
|
+
terminateProcessTree(pid);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
onEvent: (event, { dshSessionId }) => {
|
|
252
|
+
if (event.type === "session") {
|
|
253
|
+
report({ message: `dsh session ${dshSessionId} ready.`, dshSessionId });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const progress = phaseForEvent(event);
|
|
257
|
+
if (progress) {
|
|
258
|
+
report(progress);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
return { ...result, summary: summarize(result.payload.finalText, result) };
|
|
263
|
+
},
|
|
264
|
+
{ logFile }
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (execution.cancelled) {
|
|
268
|
+
process.stderr.write(`Job ${job.id} was cancelled.\n`);
|
|
269
|
+
process.exitCode = 1;
|
|
270
|
+
return execution;
|
|
271
|
+
}
|
|
272
|
+
outputCommandResult(
|
|
273
|
+
{ jobId: job.id, status: execution.exitStatus === 0 ? "completed" : "failed", ...execution },
|
|
274
|
+
execution.rendered,
|
|
275
|
+
asJson
|
|
276
|
+
);
|
|
277
|
+
process.exitCode = execution.exitStatus;
|
|
278
|
+
return execution;
|
|
279
|
+
} finally {
|
|
280
|
+
process.off("SIGINT", onSigint);
|
|
281
|
+
process.off("SIGTERM", onSigterm);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function resolveModelOption(options) {
|
|
286
|
+
return normalizeModel(options.model);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// plan and review-plan
|
|
291
|
+
|
|
292
|
+
async function handlePlan(argv) {
|
|
293
|
+
const { options, positionals } = parseCommandInput(argv, {
|
|
294
|
+
valueOptions: ["model", "cwd"],
|
|
295
|
+
booleanOptions: ["json"],
|
|
296
|
+
stopAtPositional: true
|
|
297
|
+
});
|
|
298
|
+
const cwd = resolveCommandCwd(options);
|
|
299
|
+
const request = positionals.join(" ").trim();
|
|
300
|
+
if (!request) {
|
|
301
|
+
throw new UsageError("Provide what to plan, for example: plan add a --version flag.");
|
|
302
|
+
}
|
|
303
|
+
isDshAvailable(cwd);
|
|
304
|
+
|
|
305
|
+
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
306
|
+
const model = resolveModelOption(options);
|
|
307
|
+
const prompt = interpolateTemplate(loadPromptTemplate(ROOT_DIR, "plan"), { REQUEST: request });
|
|
308
|
+
const job = createCompanionJob({
|
|
309
|
+
prefix: "plan",
|
|
310
|
+
kind: "plan",
|
|
311
|
+
title: "dsh Plan",
|
|
312
|
+
workspaceRoot,
|
|
313
|
+
cwd,
|
|
314
|
+
summary: shorten(request),
|
|
315
|
+
permissionMode: READ_ONLY,
|
|
316
|
+
model: model ?? DEFAULT_MODEL
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await executeRun({
|
|
320
|
+
job,
|
|
321
|
+
prompt,
|
|
322
|
+
permissionMode: READ_ONLY,
|
|
323
|
+
model,
|
|
324
|
+
summarize: (finalText) => shorten(firstMeaningfulLine(finalText, request)),
|
|
325
|
+
asJson: options.json
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function extractVerdict(finalText) {
|
|
330
|
+
const first = firstMeaningfulLine(finalText, "");
|
|
331
|
+
const match = first.match(/^\W*Verdict:\s*([A-Za-z-]+)/i);
|
|
332
|
+
const verdict = match ? match[1].toLowerCase() : null;
|
|
333
|
+
return VERDICTS.includes(verdict) ? verdict : "unknown";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function resolvePlanPath(cwd, workspaceRoot, rawPath) {
|
|
337
|
+
for (const base of [cwd, workspaceRoot]) {
|
|
338
|
+
const candidate = path.resolve(base, rawPath);
|
|
339
|
+
if (fs.existsSync(candidate)) {
|
|
340
|
+
return candidate;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function buildPlanFilesBlock(files, workspaceRoot) {
|
|
347
|
+
return files
|
|
348
|
+
.map((file) => {
|
|
349
|
+
const label = (path.relative(workspaceRoot, file) || path.basename(file)).replace(/"/g, "%22");
|
|
350
|
+
return `<file path="${label}">\n${fs.readFileSync(file, "utf8")}\n</file>`;
|
|
351
|
+
})
|
|
352
|
+
.join("\n\n");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function handleReviewPlan(argv) {
|
|
356
|
+
const { options, positionals } = parseCommandInput(argv, {
|
|
357
|
+
valueOptions: ["model", "cwd"],
|
|
358
|
+
booleanOptions: ["json"],
|
|
359
|
+
stopAtPositional: true
|
|
360
|
+
});
|
|
361
|
+
const cwd = resolveCommandCwd(options);
|
|
362
|
+
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
363
|
+
const [rawPath, ...focusParts] = positionals;
|
|
364
|
+
if (!rawPath) {
|
|
365
|
+
throw new UsageError("Provide the plan path: review-plan <plan-file-or-dir> [focus text].");
|
|
366
|
+
}
|
|
367
|
+
const planPath = resolvePlanPath(cwd, workspaceRoot, rawPath);
|
|
368
|
+
if (!planPath) {
|
|
369
|
+
throw new UsageError(`Plan path not found: ${rawPath}`);
|
|
370
|
+
}
|
|
371
|
+
let files;
|
|
372
|
+
try {
|
|
373
|
+
files = collectPlanFiles(planPath);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
throw new UsageError(error.message);
|
|
376
|
+
}
|
|
377
|
+
isDshAvailable(cwd);
|
|
378
|
+
|
|
379
|
+
const model = resolveModelOption(options);
|
|
380
|
+
const focus = focusParts.join(" ").trim();
|
|
381
|
+
const prompt = interpolateTemplate(loadPromptTemplate(ROOT_DIR, "review-plan"), {
|
|
382
|
+
FOCUS: focus || "No extra focus. Review the plan as a whole.",
|
|
383
|
+
PLAN_FILES: buildPlanFilesBlock(files, workspaceRoot)
|
|
384
|
+
});
|
|
385
|
+
const label = path.relative(workspaceRoot, planPath) || path.basename(planPath);
|
|
386
|
+
const job = createCompanionJob({
|
|
387
|
+
prefix: "review-plan",
|
|
388
|
+
kind: "review-plan",
|
|
389
|
+
title: "dsh Plan Review",
|
|
390
|
+
workspaceRoot,
|
|
391
|
+
cwd,
|
|
392
|
+
summary: `Review ${label}`,
|
|
393
|
+
permissionMode: READ_ONLY,
|
|
394
|
+
model: model ?? DEFAULT_MODEL
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await executeRun({
|
|
398
|
+
job,
|
|
399
|
+
prompt,
|
|
400
|
+
permissionMode: READ_ONLY,
|
|
401
|
+
model,
|
|
402
|
+
summarize: (finalText) => `Verdict: ${extractVerdict(finalText)} (${label})`,
|
|
403
|
+
asJson: options.json
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function handlePlanCandidates(argv) {
|
|
408
|
+
const { options } = parseCommandInput(argv, {
|
|
409
|
+
valueOptions: ["cwd"],
|
|
410
|
+
booleanOptions: ["json"]
|
|
411
|
+
});
|
|
412
|
+
const workspaceRoot = resolveWorkspaceRoot(resolveCommandCwd(options));
|
|
413
|
+
const latest = findLatestPlanDir(workspaceRoot);
|
|
414
|
+
outputCommandResult(
|
|
415
|
+
{ latest },
|
|
416
|
+
latest ? `Latest plan: ${latest}\n` : "No plans found under ./plans.\n",
|
|
417
|
+
options.json
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// task (rescue)
|
|
423
|
+
|
|
424
|
+
function readTaskPrompt(positionals) {
|
|
425
|
+
const fromArgs = positionals.join(" ").trim();
|
|
426
|
+
return fromArgs || readStdinIfPiped().trim();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function handleTask(argv) {
|
|
430
|
+
const { options, positionals } = parseCommandInput(argv, {
|
|
431
|
+
valueOptions: ["model", "cwd"],
|
|
432
|
+
booleanOptions: ["json", "write", "read-only", "resume-last"],
|
|
433
|
+
stopAtPositional: true
|
|
434
|
+
});
|
|
435
|
+
if (options.write && options["read-only"]) {
|
|
436
|
+
throw new UsageError("Choose either --write or --read-only.");
|
|
437
|
+
}
|
|
438
|
+
const requestedMode = options.write ? WORKSPACE_WRITE : options["read-only"] ? READ_ONLY : null;
|
|
439
|
+
const cwd = resolveCommandCwd(options);
|
|
440
|
+
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
441
|
+
const prompt = readTaskPrompt(positionals);
|
|
442
|
+
|
|
443
|
+
let permissionMode = requestedMode ?? READ_ONLY;
|
|
444
|
+
let sessionId = null;
|
|
445
|
+
if (options["resume-last"]) {
|
|
446
|
+
const candidate = resolveResumableTask(cwd);
|
|
447
|
+
if (!candidate) {
|
|
448
|
+
throw new UsageError("No previous dsh rescue session for this Claude session in this directory.");
|
|
449
|
+
}
|
|
450
|
+
if (requestedMode && requestedMode !== candidate.permissionMode) {
|
|
451
|
+
throw new UsageError(
|
|
452
|
+
`This dsh session runs in ${candidate.permissionMode}; start a new one with --fresh to change permissions.`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
permissionMode = candidate.permissionMode;
|
|
456
|
+
sessionId = candidate.dshSessionId;
|
|
457
|
+
if (!prompt) {
|
|
458
|
+
throw new UsageError("Provide a prompt to continue the previous dsh session.");
|
|
459
|
+
}
|
|
460
|
+
} else if (!prompt) {
|
|
461
|
+
throw new UsageError("Provide a prompt for dsh.");
|
|
462
|
+
}
|
|
463
|
+
isDshAvailable(cwd);
|
|
464
|
+
|
|
465
|
+
const model = resolveModelOption(options);
|
|
466
|
+
const job = createCompanionJob({
|
|
467
|
+
prefix: "task",
|
|
468
|
+
kind: "task",
|
|
469
|
+
title: "dsh Rescue",
|
|
470
|
+
workspaceRoot,
|
|
471
|
+
cwd,
|
|
472
|
+
summary: shorten(prompt),
|
|
473
|
+
permissionMode,
|
|
474
|
+
model: model ?? DEFAULT_MODEL
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
await executeRun({
|
|
478
|
+
job,
|
|
479
|
+
prompt,
|
|
480
|
+
permissionMode,
|
|
481
|
+
model,
|
|
482
|
+
sessionId,
|
|
483
|
+
summarize: (finalText) => shorten(firstMeaningfulLine(finalText, prompt)),
|
|
484
|
+
asJson: options.json
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function handleTaskResumeCandidate(argv) {
|
|
489
|
+
const { options } = parseCommandInput(argv, {
|
|
490
|
+
valueOptions: ["cwd"],
|
|
491
|
+
booleanOptions: ["json"]
|
|
492
|
+
});
|
|
493
|
+
const cwd = resolveCommandCwd(options);
|
|
494
|
+
const candidate = resolveResumableTask(cwd);
|
|
495
|
+
const payload = {
|
|
496
|
+
available: Boolean(candidate),
|
|
497
|
+
jobId: candidate?.id ?? null,
|
|
498
|
+
dshSessionId: candidate?.dshSessionId ?? null,
|
|
499
|
+
permissionMode: candidate?.permissionMode ?? null,
|
|
500
|
+
summary: candidate?.summary ?? null
|
|
501
|
+
};
|
|
502
|
+
const rendered = candidate
|
|
503
|
+
? `Resumable task found: ${candidate.id} (${candidate.permissionMode}).\n`
|
|
504
|
+
: "No resumable task found for this session.\n";
|
|
505
|
+
outputCommandResult(payload, rendered, options.json);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// setup, status, result, cancel
|
|
510
|
+
|
|
511
|
+
async function buildSetupReport(cwd) {
|
|
512
|
+
const nodeStatus = binaryAvailable("node", ["--version"], { cwd });
|
|
513
|
+
const npmStatus = binaryAvailable("npm", ["--version"], { cwd });
|
|
514
|
+
const dshStatus = getDshAvailability(cwd);
|
|
515
|
+
const dshTuiPath = findOnPath("dsh-tui");
|
|
516
|
+
|
|
517
|
+
const nextSteps = [];
|
|
518
|
+
let auth = { ok: false, detail: "not checked (dsh is not installed)" };
|
|
519
|
+
let dshDetail = dshStatus.detail;
|
|
520
|
+
|
|
521
|
+
if (!dshStatus.available) {
|
|
522
|
+
nextSteps.push("Install dsh with `npm install -g @deepseek-ai/dsh`.");
|
|
523
|
+
} else {
|
|
524
|
+
if (!dshStatus.supported) {
|
|
525
|
+
dshDetail = `${dshStatus.detail} (unsupported; this plugin expects >=0.2.0-rc.2 <0.3.0)`;
|
|
526
|
+
nextSteps.push("Install a supported dsh version: `npm install -g @deepseek-ai/dsh@0.2`.");
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
const smoke = await runHeadless({ cwd, prompt: "Reply with OK", permissionMode: READ_ONLY, timeoutMs: SMOKE_TIMEOUT_MS });
|
|
530
|
+
const ok = smoke.exitStatus === 0 && Boolean(smoke.payload.finalText.trim());
|
|
531
|
+
auth = {
|
|
532
|
+
ok,
|
|
533
|
+
detail: ok ? "headless run answered" : shorten(smoke.payload.errorMessage || "no answer from dsh", 160)
|
|
534
|
+
};
|
|
535
|
+
} catch (error) {
|
|
536
|
+
auth = { ok: false, detail: shorten(error.message, 160) };
|
|
537
|
+
}
|
|
538
|
+
if (!auth.ok) {
|
|
539
|
+
nextSteps.push("Run `!dsh-tui`, then log in with `/login`.");
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
ready: nodeStatus.available && dshStatus.available && dshStatus.supported && auth.ok,
|
|
545
|
+
node: nodeStatus,
|
|
546
|
+
npm: npmStatus,
|
|
547
|
+
dsh: { ...dshStatus, detail: dshDetail },
|
|
548
|
+
dshTui: { available: Boolean(dshTuiPath), detail: dshTuiPath ?? "not found (optional)" },
|
|
549
|
+
auth,
|
|
550
|
+
defaultModel: DEFAULT_MODEL,
|
|
551
|
+
actionsTaken: [],
|
|
552
|
+
nextSteps
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function handleSetup(argv) {
|
|
557
|
+
const { options } = parseCommandInput(argv, {
|
|
558
|
+
valueOptions: ["cwd"],
|
|
559
|
+
booleanOptions: ["json"]
|
|
560
|
+
});
|
|
561
|
+
const report = await buildSetupReport(resolveCommandCwd(options));
|
|
562
|
+
outputResult(options.json ? report : renderSetupReport(report), options.json);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isActiveJobStatus(status) {
|
|
566
|
+
return status === "queued" || status === "running";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function waitForSingleJobSnapshot(cwd, reference, options = {}) {
|
|
570
|
+
const timeoutMs = Math.max(0, Number(options.timeoutMs) || DEFAULT_STATUS_WAIT_TIMEOUT_MS);
|
|
571
|
+
const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || DEFAULT_STATUS_POLL_INTERVAL_MS);
|
|
572
|
+
const deadline = Date.now() + timeoutMs;
|
|
573
|
+
let snapshot = buildSingleJobSnapshot(cwd, reference);
|
|
574
|
+
|
|
575
|
+
while (isActiveJobStatus(snapshot.job.status) && Date.now() < deadline) {
|
|
576
|
+
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
|
|
577
|
+
snapshot = buildSingleJobSnapshot(cwd, reference);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return { ...snapshot, waitTimedOut: isActiveJobStatus(snapshot.job.status), timeoutMs };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function handleStatus(argv) {
|
|
584
|
+
const { options, positionals } = parseCommandInput(argv, {
|
|
585
|
+
valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"],
|
|
586
|
+
booleanOptions: ["json", "all", "wait"]
|
|
587
|
+
});
|
|
588
|
+
const cwd = resolveCommandCwd(options);
|
|
589
|
+
const reference = positionals[0] ?? "";
|
|
590
|
+
|
|
591
|
+
if (reference) {
|
|
592
|
+
const snapshot = options.wait
|
|
593
|
+
? await waitForSingleJobSnapshot(cwd, reference, {
|
|
594
|
+
timeoutMs: options["timeout-ms"],
|
|
595
|
+
pollIntervalMs: options["poll-interval-ms"]
|
|
596
|
+
})
|
|
597
|
+
: buildSingleJobSnapshot(cwd, reference);
|
|
598
|
+
outputCommandResult(snapshot, renderJobStatusReport(snapshot.job), options.json);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (options.wait) {
|
|
603
|
+
throw new UsageError("`status --wait` requires a job id.");
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const report = buildStatusSnapshot(cwd, { all: options.all });
|
|
607
|
+
outputResult(options.json ? report : renderStatusReport(report), options.json);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function handleResult(argv) {
|
|
611
|
+
const { options, positionals } = parseCommandInput(argv, {
|
|
612
|
+
valueOptions: ["cwd"],
|
|
613
|
+
booleanOptions: ["json"]
|
|
614
|
+
});
|
|
615
|
+
const cwd = resolveCommandCwd(options);
|
|
616
|
+
const { workspaceRoot, job } = resolveResultJob(cwd, positionals[0] ?? "");
|
|
617
|
+
const storedJob = readStoredJob(workspaceRoot, job.id);
|
|
618
|
+
outputCommandResult({ job, storedJob }, renderStoredJobResult(job, storedJob), options.json);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function handleCancel(argv) {
|
|
622
|
+
const { options, positionals } = parseCommandInput(argv, {
|
|
623
|
+
valueOptions: ["cwd"],
|
|
624
|
+
booleanOptions: ["json"]
|
|
625
|
+
});
|
|
626
|
+
const cwd = resolveCommandCwd(options);
|
|
627
|
+
const { workspaceRoot, job } = resolveCancelableJob(cwd, positionals[0] ?? "", { env: process.env });
|
|
628
|
+
const existing = readStoredJob(workspaceRoot, job.id) ?? {};
|
|
629
|
+
const dshPid = existing.dshPid ?? job.dshPid ?? null;
|
|
630
|
+
const companionPid = existing.pid ?? job.pid ?? null;
|
|
631
|
+
|
|
632
|
+
// Cancellation is terminal: record it under the state lock before any process is signalled, so a
|
|
633
|
+
// racing completion or progress write cannot overwrite it.
|
|
634
|
+
const completedAt = nowIso();
|
|
635
|
+
const nextJob = {
|
|
636
|
+
...job,
|
|
637
|
+
status: "cancelled",
|
|
638
|
+
phase: "cancelled",
|
|
639
|
+
pid: null,
|
|
640
|
+
completedAt,
|
|
641
|
+
errorMessage: "Cancelled by user."
|
|
642
|
+
};
|
|
643
|
+
withStateLock(workspaceRoot, () => {
|
|
644
|
+
const latest = readStoredJob(workspaceRoot, job.id) ?? existing;
|
|
645
|
+
writeJobFile(workspaceRoot, job.id, { ...latest, ...nextJob, cancelledAt: completedAt });
|
|
646
|
+
upsertJob(workspaceRoot, {
|
|
647
|
+
id: job.id,
|
|
648
|
+
status: "cancelled",
|
|
649
|
+
phase: "cancelled",
|
|
650
|
+
pid: null,
|
|
651
|
+
errorMessage: "Cancelled by user.",
|
|
652
|
+
completedAt
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
if (Number.isFinite(dshPid)) {
|
|
657
|
+
terminateProcessTree(dshPid);
|
|
658
|
+
}
|
|
659
|
+
if (Number.isFinite(companionPid) && companionPid !== process.pid && processCommandIncludes(companionPid, "dsh-companion")) {
|
|
660
|
+
try {
|
|
661
|
+
process.kill(companionPid, "SIGTERM");
|
|
662
|
+
} catch {
|
|
663
|
+
// The companion already exited.
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
appendLogLine(job.logFile, "Cancelled by user.");
|
|
667
|
+
|
|
668
|
+
outputCommandResult({ jobId: job.id, status: "cancelled", title: job.title }, renderCancelReport(nextJob), options.json);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function main() {
|
|
672
|
+
if (process.env[CHILD_ENV] === "1") {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const [subcommand, ...argv] = process.argv.slice(2);
|
|
676
|
+
if (!subcommand || subcommand === "help" || subcommand === "--help") {
|
|
677
|
+
printUsage();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
switch (subcommand) {
|
|
682
|
+
case "setup":
|
|
683
|
+
await handleSetup(argv);
|
|
684
|
+
break;
|
|
685
|
+
case "plan":
|
|
686
|
+
await handlePlan(argv);
|
|
687
|
+
break;
|
|
688
|
+
case "review-plan":
|
|
689
|
+
await handleReviewPlan(argv);
|
|
690
|
+
break;
|
|
691
|
+
case "plan-candidates":
|
|
692
|
+
handlePlanCandidates(argv);
|
|
693
|
+
break;
|
|
694
|
+
case "task":
|
|
695
|
+
await handleTask(argv);
|
|
696
|
+
break;
|
|
697
|
+
case "task-resume-candidate":
|
|
698
|
+
handleTaskResumeCandidate(argv);
|
|
699
|
+
break;
|
|
700
|
+
case "status":
|
|
701
|
+
await handleStatus(argv);
|
|
702
|
+
break;
|
|
703
|
+
case "result":
|
|
704
|
+
handleResult(argv);
|
|
705
|
+
break;
|
|
706
|
+
case "cancel":
|
|
707
|
+
await handleCancel(argv);
|
|
708
|
+
break;
|
|
709
|
+
default:
|
|
710
|
+
throw new UsageError(`Unknown subcommand: ${subcommand}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
|
|
715
|
+
main().catch((error) => {
|
|
716
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
717
|
+
process.stderr.write(`${message}\n`);
|
|
718
|
+
process.exitCode = error?.exitCode ?? 1;
|
|
719
|
+
});
|
|
720
|
+
}
|