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,242 @@
1
+ import fs from "node:fs";
2
+ import process from "node:process";
3
+
4
+ import { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, withStateLock, writeJobFile } from "./state.mjs";
5
+
6
+ export const SESSION_ID_ENV = "DSH_COMPANION_SESSION_ID";
7
+
8
+ export function nowIso() {
9
+ return new Date().toISOString();
10
+ }
11
+
12
+ function normalizeProgressEvent(value) {
13
+ if (value && typeof value === "object" && !Array.isArray(value)) {
14
+ return {
15
+ message: String(value.message ?? "").trim(),
16
+ phase: typeof value.phase === "string" && value.phase.trim() ? value.phase.trim() : null,
17
+ dshSessionId:
18
+ typeof value.dshSessionId === "string" && value.dshSessionId.trim() ? value.dshSessionId.trim() : null,
19
+ stderrMessage: value.stderrMessage == null ? null : String(value.stderrMessage).trim(),
20
+ logTitle: typeof value.logTitle === "string" && value.logTitle.trim() ? value.logTitle.trim() : null,
21
+ logBody: value.logBody == null ? null : String(value.logBody).trimEnd()
22
+ };
23
+ }
24
+
25
+ return {
26
+ message: String(value ?? "").trim(),
27
+ phase: null,
28
+ dshSessionId: null,
29
+ stderrMessage: String(value ?? "").trim(),
30
+ logTitle: null,
31
+ logBody: null
32
+ };
33
+ }
34
+
35
+ export function appendLogLine(logFile, message) {
36
+ const normalized = String(message ?? "").trim();
37
+ if (!logFile || !normalized) {
38
+ return;
39
+ }
40
+ fs.appendFileSync(logFile, `[${nowIso()}] ${normalized}\n`, "utf8");
41
+ }
42
+
43
+ export function appendLogBlock(logFile, title, body) {
44
+ if (!logFile || !body) {
45
+ return;
46
+ }
47
+ fs.appendFileSync(logFile, `\n[${nowIso()}] ${title}\n${String(body).trimEnd()}\n`, "utf8");
48
+ }
49
+
50
+ export function createJobLogFile(workspaceRoot, jobId, title) {
51
+ const logFile = resolveJobLogFile(workspaceRoot, jobId);
52
+ fs.writeFileSync(logFile, "", "utf8");
53
+ if (title) {
54
+ appendLogLine(logFile, `Starting ${title}.`);
55
+ }
56
+ return logFile;
57
+ }
58
+
59
+ export function createJobRecord(base, options = {}) {
60
+ const env = options.env ?? process.env;
61
+ const sessionId = env[options.sessionIdEnv ?? SESSION_ID_ENV];
62
+ return {
63
+ ...base,
64
+ createdAt: nowIso(),
65
+ ...(sessionId ? { sessionId } : {})
66
+ };
67
+ }
68
+
69
+ function readStoredJobOrNull(workspaceRoot, jobId) {
70
+ const jobFile = resolveJobFile(workspaceRoot, jobId);
71
+ if (!fs.existsSync(jobFile)) {
72
+ return null;
73
+ }
74
+ return readJobFile(jobFile);
75
+ }
76
+
77
+ // Records phase and dsh session id changes in both the state index and the job file.
78
+ export function createJobProgressUpdater(workspaceRoot, jobId) {
79
+ let lastPhase = null;
80
+ let lastDshSessionId = null;
81
+
82
+ return (event) => {
83
+ const normalized = normalizeProgressEvent(event);
84
+ const patch = { id: jobId };
85
+ let changed = false;
86
+
87
+ if (normalized.phase && normalized.phase !== lastPhase) {
88
+ lastPhase = normalized.phase;
89
+ patch.phase = normalized.phase;
90
+ changed = true;
91
+ }
92
+
93
+ if (normalized.dshSessionId && normalized.dshSessionId !== lastDshSessionId) {
94
+ lastDshSessionId = normalized.dshSessionId;
95
+ patch.dshSessionId = normalized.dshSessionId;
96
+ changed = true;
97
+ }
98
+
99
+ if (!changed) {
100
+ return;
101
+ }
102
+
103
+ withStateLock(workspaceRoot, () => {
104
+ const stored = readStoredJobOrNull(workspaceRoot, jobId);
105
+ if (stored?.status === "cancelled") {
106
+ return;
107
+ }
108
+ upsertJob(workspaceRoot, patch);
109
+ if (stored) {
110
+ writeJobFile(workspaceRoot, jobId, { ...stored, ...patch });
111
+ }
112
+ });
113
+ };
114
+ }
115
+
116
+ // Records the dsh process-group leader pid so cancel can kill the whole group.
117
+ export function recordDshPid(workspaceRoot, jobId, dshPid) {
118
+ return withStateLock(workspaceRoot, () => {
119
+ const stored = readStoredJobOrNull(workspaceRoot, jobId);
120
+ if (stored?.status === "cancelled") {
121
+ return false;
122
+ }
123
+ upsertJob(workspaceRoot, { id: jobId, dshPid });
124
+ if (stored) {
125
+ writeJobFile(workspaceRoot, jobId, { ...stored, dshPid });
126
+ }
127
+ return true;
128
+ });
129
+ }
130
+
131
+ export function createProgressReporter({ stderr = false, logFile = null, onEvent = null } = {}) {
132
+ if (!stderr && !logFile && !onEvent) {
133
+ return null;
134
+ }
135
+
136
+ return (eventOrMessage) => {
137
+ const event = normalizeProgressEvent(eventOrMessage);
138
+ const stderrMessage = event.stderrMessage ?? event.message;
139
+ if (stderr && stderrMessage) {
140
+ process.stderr.write(`[dsh] ${stderrMessage}\n`);
141
+ }
142
+ appendLogLine(logFile, event.message);
143
+ appendLogBlock(logFile, event.logTitle, event.logBody);
144
+ onEvent?.(event);
145
+ };
146
+ }
147
+
148
+ export function isJobCancelled(workspaceRoot, jobId) {
149
+ return readStoredJobOrNull(workspaceRoot, jobId)?.status === "cancelled";
150
+ }
151
+
152
+ /**
153
+ * Runner contract: { exitStatus, payload, rendered, summary, dshSessionId, model, permissionMode, cwd }.
154
+ * A job that was cancelled while running stays cancelled; cancellation is terminal.
155
+ */
156
+ export async function runTrackedJob(job, runner, options = {}) {
157
+ const logFile = options.logFile ?? job.logFile ?? null;
158
+ const runningRecord = {
159
+ ...job,
160
+ status: "running",
161
+ startedAt: nowIso(),
162
+ phase: "starting",
163
+ pid: process.pid,
164
+ logFile
165
+ };
166
+ if (isJobCancelled(job.workspaceRoot, job.id)) {
167
+ throw new Error(`Job ${job.id} was cancelled before it started.`);
168
+ }
169
+ writeJobFile(job.workspaceRoot, job.id, runningRecord);
170
+ upsertJob(job.workspaceRoot, runningRecord);
171
+
172
+ try {
173
+ const execution = await runner();
174
+ const outcome = withStateLock(job.workspaceRoot, () => {
175
+ // Cancellation is terminal: it is checked and the completion is written under one lock.
176
+ if (isJobCancelled(job.workspaceRoot, job.id)) {
177
+ return "cancelled";
178
+ }
179
+ const completionStatus = execution.exitStatus === 0 ? "completed" : "failed";
180
+ const completedAt = nowIso();
181
+ const finalState = {
182
+ dshSessionId: execution.dshSessionId ?? null,
183
+ model: execution.model ?? null,
184
+ permissionMode: execution.permissionMode ?? null,
185
+ cwd: execution.cwd ?? job.cwd ?? null
186
+ };
187
+ const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord;
188
+ writeJobFile(job.workspaceRoot, job.id, {
189
+ ...existing,
190
+ status: completionStatus,
191
+ ...finalState,
192
+ pid: null,
193
+ phase: completionStatus === "completed" ? "done" : "failed",
194
+ completedAt,
195
+ result: execution.payload,
196
+ rendered: execution.rendered
197
+ });
198
+ upsertJob(job.workspaceRoot, {
199
+ id: job.id,
200
+ status: completionStatus,
201
+ ...finalState,
202
+ summary: execution.summary,
203
+ phase: completionStatus === "completed" ? "done" : "failed",
204
+ pid: null,
205
+ completedAt
206
+ });
207
+ return completionStatus;
208
+ });
209
+ if (outcome === "cancelled") {
210
+ return { ...execution, cancelled: true };
211
+ }
212
+ appendLogBlock(logFile, "Final output", execution.rendered);
213
+ return execution;
214
+ } catch (error) {
215
+ const errorMessage = error instanceof Error ? error.message : String(error);
216
+ withStateLock(job.workspaceRoot, () => {
217
+ if (isJobCancelled(job.workspaceRoot, job.id)) {
218
+ return;
219
+ }
220
+ const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord;
221
+ const completedAt = nowIso();
222
+ writeJobFile(job.workspaceRoot, job.id, {
223
+ ...existing,
224
+ status: "failed",
225
+ phase: "failed",
226
+ errorMessage,
227
+ pid: null,
228
+ completedAt,
229
+ logFile: logFile ?? existing.logFile ?? null
230
+ });
231
+ upsertJob(job.workspaceRoot, {
232
+ id: job.id,
233
+ status: "failed",
234
+ phase: "failed",
235
+ pid: null,
236
+ errorMessage,
237
+ completedAt
238
+ });
239
+ });
240
+ throw error;
241
+ }
242
+ }
@@ -0,0 +1,9 @@
1
+ import { ensureGitRepository } from "./git.mjs";
2
+
3
+ export function resolveWorkspaceRoot(cwd) {
4
+ try {
5
+ return ensureGitRepository(cwd);
6
+ } catch {
7
+ return cwd;
8
+ }
9
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import process from "node:process";
5
+
6
+ import { CHILD_ENV } from "./lib/dsh.mjs";
7
+ import { processCommandIncludes, terminateProcessTree } from "./lib/process.mjs";
8
+ import { listJobs, readJobFile, resolveJobFile, resolveStateFile, upsertJob, withStateLock, writeJobFile } from "./lib/state.mjs";
9
+ import { nowIso, SESSION_ID_ENV } from "./lib/tracked-jobs.mjs";
10
+ import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
11
+
12
+ const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
13
+
14
+ function readHookInput() {
15
+ const raw = fs.readFileSync(0, "utf8").trim();
16
+ if (!raw) {
17
+ return {};
18
+ }
19
+ return JSON.parse(raw);
20
+ }
21
+
22
+ function shellEscape(value) {
23
+ return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
24
+ }
25
+
26
+ function appendEnvVar(name, value) {
27
+ if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") {
28
+ return;
29
+ }
30
+ fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `export ${name}=${shellEscape(value)}\n`, "utf8");
31
+ }
32
+
33
+ function killQuietly(pid, killer) {
34
+ if (!Number.isFinite(pid)) {
35
+ return;
36
+ }
37
+ try {
38
+ killer(pid);
39
+ } catch {
40
+ // Ignore teardown failures during session shutdown.
41
+ }
42
+ }
43
+
44
+ function cleanupSessionJobs(cwd, sessionId) {
45
+ if (!cwd || !sessionId) {
46
+ return;
47
+ }
48
+
49
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
50
+ if (!fs.existsSync(resolveStateFile(workspaceRoot))) {
51
+ return;
52
+ }
53
+
54
+ for (const job of listJobs(workspaceRoot)) {
55
+ const stillRunning = job.status === "queued" || job.status === "running";
56
+ if (job.sessionId !== sessionId || !stillRunning) {
57
+ continue;
58
+ }
59
+
60
+ // Mark the job cancelled first, under the state lock, so a racing completion cannot overwrite it.
61
+ const completedAt = nowIso();
62
+ const cancelled = {
63
+ status: "cancelled",
64
+ phase: "cancelled",
65
+ pid: null,
66
+ errorMessage: "Session ended.",
67
+ completedAt
68
+ };
69
+ withStateLock(workspaceRoot, () => {
70
+ const jobFile = resolveJobFile(workspaceRoot, job.id);
71
+ const stored = fs.existsSync(jobFile) ? readJobFile(jobFile) : job;
72
+ writeJobFile(workspaceRoot, job.id, { ...stored, ...cancelled });
73
+ upsertJob(workspaceRoot, { id: job.id, ...cancelled });
74
+ });
75
+
76
+ killQuietly(job.dshPid, terminateProcessTree);
77
+ killQuietly(job.pid, (pid) => {
78
+ if (processCommandIncludes(pid, "dsh-companion")) {
79
+ process.kill(pid, "SIGTERM");
80
+ }
81
+ });
82
+ }
83
+ }
84
+
85
+ function handleSessionStart(input) {
86
+ appendEnvVar(SESSION_ID_ENV, input.session_id);
87
+ appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
88
+ }
89
+
90
+ function handleSessionEnd(input) {
91
+ cleanupSessionJobs(input.cwd || process.cwd(), input.session_id || process.env[SESSION_ID_ENV]);
92
+ }
93
+
94
+ function main() {
95
+ if (process.env[CHILD_ENV] === "1") {
96
+ return;
97
+ }
98
+ const input = readHookInput();
99
+ const eventName = process.argv[2] ?? input.hook_event_name ?? "";
100
+
101
+ if (eventName === "SessionStart") {
102
+ handleSessionStart(input);
103
+ return;
104
+ }
105
+
106
+ if (eventName === "SessionEnd") {
107
+ handleSessionEnd(input);
108
+ }
109
+ }
110
+
111
+ try {
112
+ main();
113
+ } catch (error) {
114
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
115
+ process.exit(1);
116
+ }
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: dsh-cli-runtime
3
+ description: Internal helper contract for calling the dsh-companion runtime from Claude Code
4
+ user-invocable: false
5
+ ---
6
+
7
+ # dsh Runtime
8
+
9
+ Use this skill only inside the `dsh:dsh-rescue` subagent.
10
+
11
+ Primary helper:
12
+ - `node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" task "<raw arguments>"`
13
+
14
+ Execution rules:
15
+ - Set the Bash `timeout` to `600000` and put the task text between single quotes (escape each `'` as `'\''`), after all flags.
16
+ - The rescue subagent is a forwarder, not an orchestrator. Its only job is to invoke `task` once and return that stdout unchanged.
17
+ - Prefer the helper over hand-rolled `dsh` command lines or any other Bash activity.
18
+ - Do not call `setup`, `plan`, `review-plan`, `status`, `result`, or `cancel` from `dsh:dsh-rescue`.
19
+ - Use `task` for every rescue request, including diagnosis, planning, research, and explicit fix requests.
20
+ - Leave the model unset by default. Add `--model` only when the user explicitly asks for one. The aliases are `flash` (deepseek-flash) and `pro` (deepseek-v4-pro).
21
+
22
+ Flags the `task` helper accepts:
23
+ - `--write`: run with the workspace-write sandbox.
24
+ - `--read-only`: run with the read-only sandbox. This is the helper's default when neither flag is given, so the agent always states the mode it wants.
25
+ - `--resume-last`: continue the previous rescue session of this Claude session in this directory.
26
+ - `--model <flash|pro|name>`.
27
+
28
+ Command selection:
29
+ - Use exactly one `task` invocation per rescue handoff.
30
+ - Strip `--background` and `--wait` before calling `task`. They are Claude-side execution control only.
31
+ - `--resume`: strip that token from the task text and add `--resume-last`.
32
+ - `--fresh`: strip that token from the task text and do not add `--resume-last`.
33
+ - Fresh run: add `--write` unless the user asked for read-only, diagnosis-only or research-only work, in which case add `--read-only`.
34
+ - Resumed run: add no mode flag unless the user explicitly asked for one. The session keeps the mode it was created with, and asking for a different mode fails with an instruction to use `--fresh`.
35
+
36
+ Safety rules:
37
+ - Preserve the user's task text as-is apart from stripping routing flags.
38
+ - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own.
39
+ - Return the stdout of the `task` command exactly as-is.
40
+ - If the Bash call fails, return its error message and nothing else.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: dsh-result-handling
3
+ description: Internal guidance for presenting dsh helper output back to the user
4
+ user-invocable: false
5
+ ---
6
+
7
+ # dsh Result Handling
8
+
9
+ When the helper returns dsh output:
10
+ - Present dsh's final text verbatim, including the `dsh session: … · model: … · mode: …` footer.
11
+ - For `/dsh:review-plan` output, keep the `Verdict:` line first and keep findings in the order dsh gave them.
12
+ - Use the file paths and line numbers exactly as dsh reports them.
13
+ - Preserve evidence boundaries. If dsh marked something as an inference, uncertainty, or open question, keep that distinction.
14
+ - If dsh made edits (rescue in `workspace-write` mode), say so explicitly and list the touched files when the helper provides them.
15
+ - Do not turn a failed or incomplete dsh run into a Claude-side implementation attempt. Report the failure and stop.
16
+ - If dsh was never successfully invoked, do not generate a substitute answer.
17
+ - CRITICAL: After presenting a plan or a plan review, STOP. Do not apply the plan and do not edit the reviewed plan. Explicitly ask the user what, if anything, they want done before touching a single file. Auto-applying fixes from a plan or review is strictly forbidden, even if the fix is obvious.
18
+ - If the helper reports a failed dsh run, include the most actionable stderr lines and stop there instead of guessing.
19
+ - If the helper reports that setup or authentication is required, direct the user to `/dsh:setup` and do not improvise alternate auth flows.