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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +11 -0
  4. package/README.md +96 -0
  5. package/package.json +12 -0
  6. package/plugins/dsh/.claude-plugin/plugin.json +6 -0
  7. package/plugins/dsh/CHANGELOG.md +8 -0
  8. package/plugins/dsh/LICENSE +201 -0
  9. package/plugins/dsh/NOTICE +11 -0
  10. package/plugins/dsh/agents/dsh-rescue.md +41 -0
  11. package/plugins/dsh/commands/cancel.md +8 -0
  12. package/plugins/dsh/commands/plan.md +54 -0
  13. package/plugins/dsh/commands/rescue.md +51 -0
  14. package/plugins/dsh/commands/result.md +15 -0
  15. package/plugins/dsh/commands/review-plan.md +69 -0
  16. package/plugins/dsh/commands/setup.md +37 -0
  17. package/plugins/dsh/commands/status.md +17 -0
  18. package/plugins/dsh/hooks/hooks.json +27 -0
  19. package/plugins/dsh/prompts/plan.md +21 -0
  20. package/plugins/dsh/prompts/review-plan.md +25 -0
  21. package/plugins/dsh/scripts/dsh-companion.mjs +720 -0
  22. package/plugins/dsh/scripts/lib/args.mjs +130 -0
  23. package/plugins/dsh/scripts/lib/dsh.mjs +238 -0
  24. package/plugins/dsh/scripts/lib/fs.mjs +40 -0
  25. package/plugins/dsh/scripts/lib/git.mjs +14 -0
  26. package/plugins/dsh/scripts/lib/job-control.mjs +314 -0
  27. package/plugins/dsh/scripts/lib/plans.mjs +70 -0
  28. package/plugins/dsh/scripts/lib/process.mjs +156 -0
  29. package/plugins/dsh/scripts/lib/prompts.mjs +13 -0
  30. package/plugins/dsh/scripts/lib/render.mjs +231 -0
  31. package/plugins/dsh/scripts/lib/state.mjs +258 -0
  32. package/plugins/dsh/scripts/lib/tracked-jobs.mjs +242 -0
  33. package/plugins/dsh/scripts/lib/workspace.mjs +9 -0
  34. package/plugins/dsh/scripts/session-lifecycle-hook.mjs +116 -0
  35. package/plugins/dsh/skills/dsh-cli-runtime/SKILL.md +40 -0
  36. 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
+ }