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,231 @@
1
+ function formatJobLine(job) {
2
+ const parts = [job.id, `${job.status || "unknown"}`];
3
+ if (job.kindLabel) {
4
+ parts.push(job.kindLabel);
5
+ }
6
+ if (job.title) {
7
+ parts.push(job.title);
8
+ }
9
+ return parts.join(" | ");
10
+ }
11
+
12
+ function escapeMarkdownCell(value) {
13
+ return String(value ?? "")
14
+ .replace(/\|/g, "\\|")
15
+ .replace(/\r?\n/g, " ")
16
+ .trim();
17
+ }
18
+
19
+ function formatDshResumeCommand(job) {
20
+ if (!job?.dshSessionId) {
21
+ return null;
22
+ }
23
+ return `dsh tui --resume ${job.dshSessionId}`;
24
+ }
25
+
26
+ function isActive(job) {
27
+ return job.status === "queued" || job.status === "running";
28
+ }
29
+
30
+ function appendActiveJobsTable(lines, jobs) {
31
+ lines.push("Active jobs:");
32
+ lines.push("| Job | Kind | Status | Phase | Elapsed | dsh Session ID | Summary | Actions |");
33
+ lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |");
34
+ for (const job of jobs) {
35
+ const actions = [`/dsh:status ${job.id}`];
36
+ if (isActive(job)) {
37
+ actions.push(`/dsh:cancel ${job.id}`);
38
+ }
39
+ lines.push(
40
+ `| ${escapeMarkdownCell(job.id)} | ${escapeMarkdownCell(job.kindLabel)} | ${escapeMarkdownCell(job.status)} | ${escapeMarkdownCell(job.phase ?? "")} | ${escapeMarkdownCell(job.elapsed ?? "")} | ${escapeMarkdownCell(job.dshSessionId ?? "")} | ${escapeMarkdownCell(job.summary ?? "")} | ${actions.map((action) => `\`${action}\``).join("<br>")} |`
41
+ );
42
+ }
43
+ }
44
+
45
+ function pushJobDetails(lines, job, options = {}) {
46
+ lines.push(`- ${formatJobLine(job)}`);
47
+ if (job.summary) {
48
+ lines.push(` Summary: ${job.summary}`);
49
+ }
50
+ if (job.phase) {
51
+ lines.push(` Phase: ${job.phase}`);
52
+ }
53
+ if (options.showElapsed && job.elapsed) {
54
+ lines.push(` Elapsed: ${job.elapsed}`);
55
+ }
56
+ if (options.showDuration && job.duration) {
57
+ lines.push(` Duration: ${job.duration}`);
58
+ }
59
+ if (job.permissionMode) {
60
+ lines.push(` Mode: ${job.permissionMode}`);
61
+ }
62
+ if (job.dshSessionId) {
63
+ lines.push(` dsh session ID: ${job.dshSessionId}`);
64
+ }
65
+ const resumeCommand = formatDshResumeCommand(job);
66
+ if (resumeCommand) {
67
+ lines.push(` Resume in dsh: ${resumeCommand}`);
68
+ }
69
+ if (job.logFile && options.showLog) {
70
+ lines.push(` Log: ${job.logFile}`);
71
+ }
72
+ if (isActive(job) && options.showCancelHint) {
73
+ lines.push(` Cancel: /dsh:cancel ${job.id}`);
74
+ }
75
+ if (!isActive(job) && options.showResultHint) {
76
+ lines.push(` Result: /dsh:result ${job.id}`);
77
+ }
78
+ if (job.progressPreview?.length) {
79
+ lines.push(" Progress:");
80
+ for (const line of job.progressPreview) {
81
+ lines.push(` ${line}`);
82
+ }
83
+ }
84
+ }
85
+
86
+ export function renderSetupReport(report) {
87
+ const lines = [
88
+ "# dsh Setup",
89
+ "",
90
+ `Status: ${report.ready ? "ready" : "needs attention"}`,
91
+ "",
92
+ "Checks:",
93
+ `- node: ${report.node.detail}`,
94
+ `- npm: ${report.npm.detail}`,
95
+ `- dsh: ${report.dsh.detail}`,
96
+ `- dsh-tui: ${report.dshTui.detail}`,
97
+ `- auth: ${report.auth.detail}`,
98
+ `- default model: ${report.defaultModel}`,
99
+ ""
100
+ ];
101
+
102
+ if (report.actionsTaken.length > 0) {
103
+ lines.push("Actions taken:");
104
+ for (const action of report.actionsTaken) {
105
+ lines.push(`- ${action}`);
106
+ }
107
+ lines.push("");
108
+ }
109
+
110
+ if (report.nextSteps.length > 0) {
111
+ lines.push("Next steps:");
112
+ for (const step of report.nextSteps) {
113
+ lines.push(`- ${step}`);
114
+ }
115
+ }
116
+
117
+ return `${lines.join("\n").trimEnd()}\n`;
118
+ }
119
+
120
+ /** Prints dsh's final text verbatim, then a footer naming the session, model and mode. */
121
+ export function renderRunResult({ title, finalText, errorMessage, dshSessionId, model, permissionMode }) {
122
+ const lines = [];
123
+ const text = String(finalText ?? "").trimEnd();
124
+ if (text) {
125
+ lines.push(text);
126
+ } else if (errorMessage) {
127
+ lines.push(`${title ?? "dsh"} failed: ${errorMessage}`);
128
+ } else {
129
+ lines.push("dsh did not return a final message.");
130
+ }
131
+ const footer = [
132
+ `dsh session: ${dshSessionId ?? "unknown"}`,
133
+ `model: ${model ?? "default"}`,
134
+ `mode: ${permissionMode ?? "unknown"}`
135
+ ].join(" · ");
136
+ lines.push("", footer);
137
+ return `${lines.join("\n")}\n`;
138
+ }
139
+
140
+ export function renderStatusReport(report) {
141
+ const lines = ["# dsh Status", ""];
142
+
143
+ if (report.running.length > 0) {
144
+ appendActiveJobsTable(lines, report.running);
145
+ lines.push("");
146
+ lines.push("Live details:");
147
+ for (const job of report.running) {
148
+ pushJobDetails(lines, job, {
149
+ showElapsed: true,
150
+ showLog: true
151
+ });
152
+ }
153
+ lines.push("");
154
+ }
155
+
156
+ if (report.latestFinished) {
157
+ lines.push("Latest finished:");
158
+ pushJobDetails(lines, report.latestFinished, {
159
+ showDuration: true,
160
+ showLog: report.latestFinished.status === "failed"
161
+ });
162
+ lines.push("");
163
+ }
164
+
165
+ if (report.recent.length > 0) {
166
+ lines.push("Recent jobs:");
167
+ for (const job of report.recent) {
168
+ pushJobDetails(lines, job, {
169
+ showDuration: true,
170
+ showLog: job.status === "failed"
171
+ });
172
+ }
173
+ lines.push("");
174
+ } else if (report.running.length === 0 && !report.latestFinished) {
175
+ lines.push("No jobs recorded yet.", "");
176
+ }
177
+
178
+ return `${lines.join("\n").trimEnd()}\n`;
179
+ }
180
+
181
+ export function renderJobStatusReport(job) {
182
+ const lines = ["# dsh Job Status", ""];
183
+ pushJobDetails(lines, job, {
184
+ showElapsed: isActive(job),
185
+ showDuration: !isActive(job),
186
+ showLog: true,
187
+ showCancelHint: true,
188
+ showResultHint: true
189
+ });
190
+ return `${lines.join("\n").trimEnd()}\n`;
191
+ }
192
+
193
+ export function renderStoredJobResult(job, storedJob) {
194
+ if (storedJob?.rendered) {
195
+ return storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`;
196
+ }
197
+
198
+ const dshSessionId = storedJob?.dshSessionId ?? job.dshSessionId ?? null;
199
+ const lines = [`# ${job.title ?? "dsh Result"}`, "", `Job: ${job.id}`, `Status: ${job.status}`];
200
+
201
+ if (dshSessionId) {
202
+ lines.push(`dsh session ID: ${dshSessionId}`);
203
+ lines.push(`Resume in dsh: dsh tui --resume ${dshSessionId}`);
204
+ }
205
+ if (job.summary) {
206
+ lines.push(`Summary: ${job.summary}`);
207
+ }
208
+ if (job.errorMessage) {
209
+ lines.push("", job.errorMessage);
210
+ } else if (storedJob?.errorMessage) {
211
+ lines.push("", storedJob.errorMessage);
212
+ } else {
213
+ lines.push("", "No captured result payload was stored for this job.");
214
+ }
215
+
216
+ return `${lines.join("\n").trimEnd()}\n`;
217
+ }
218
+
219
+ export function renderCancelReport(job) {
220
+ const lines = ["# dsh Cancel", "", `Cancelled ${job.id}.`, ""];
221
+
222
+ if (job.title) {
223
+ lines.push(`- Title: ${job.title}`);
224
+ }
225
+ if (job.summary) {
226
+ lines.push(`- Summary: ${job.summary}`);
227
+ }
228
+ lines.push("- Check `/dsh:status` for the updated queue.");
229
+
230
+ return `${lines.join("\n").trimEnd()}\n`;
231
+ }
@@ -0,0 +1,258 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { resolveWorkspaceRoot } from "./workspace.mjs";
7
+
8
+ const STATE_VERSION = 1;
9
+ const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
10
+ const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "dsh-companion");
11
+ const STATE_FILE_NAME = "state.json";
12
+ const JOBS_DIR_NAME = "jobs";
13
+ const MAX_JOBS = 50;
14
+ const LOCK_DIR_NAME = ".lock";
15
+ const LOCK_STALE_MS = 10000;
16
+ const LOCK_TIMEOUT_MS = 8000;
17
+ const lockDepth = new Map();
18
+
19
+ function sleepSync(ms) {
20
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
21
+ }
22
+
23
+ function atomicWrite(file, text) {
24
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
25
+ fs.writeFileSync(tmp, text, "utf8");
26
+ fs.renameSync(tmp, file);
27
+ }
28
+
29
+ /**
30
+ * Runs `fn` while holding the per-workspace state lock, so read-modify-write sequences from
31
+ * concurrent companions, hooks and cancels cannot overwrite each other. Re-entrant within a process.
32
+ */
33
+ export function withStateLock(cwd, fn) {
34
+ const stateDir = resolveStateDir(cwd);
35
+ const depth = lockDepth.get(stateDir) ?? 0;
36
+ if (depth > 0) {
37
+ lockDepth.set(stateDir, depth + 1);
38
+ try {
39
+ return fn();
40
+ } finally {
41
+ lockDepth.set(stateDir, depth);
42
+ }
43
+ }
44
+
45
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
46
+ const lockDir = path.join(stateDir, LOCK_DIR_NAME);
47
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
48
+ for (;;) {
49
+ try {
50
+ fs.mkdirSync(lockDir);
51
+ break;
52
+ } catch (error) {
53
+ if (error?.code !== "EEXIST") {
54
+ throw error;
55
+ }
56
+ try {
57
+ if (Date.now() - fs.statSync(lockDir).mtimeMs > LOCK_STALE_MS) {
58
+ fs.rmSync(lockDir, { recursive: true, force: true });
59
+ continue;
60
+ }
61
+ } catch {
62
+ continue;
63
+ }
64
+ if (Date.now() > deadline) {
65
+ throw new Error("Timed out waiting for the dsh state lock.");
66
+ }
67
+ sleepSync(15);
68
+ }
69
+ }
70
+
71
+ lockDepth.set(stateDir, 1);
72
+ try {
73
+ return fn();
74
+ } finally {
75
+ lockDepth.delete(stateDir);
76
+ fs.rmSync(lockDir, { recursive: true, force: true });
77
+ }
78
+ }
79
+
80
+ function nowIso() {
81
+ return new Date().toISOString();
82
+ }
83
+
84
+ function defaultState() {
85
+ return {
86
+ version: STATE_VERSION,
87
+ config: {},
88
+ jobs: []
89
+ };
90
+ }
91
+
92
+ export function resolveStateDir(cwd) {
93
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
94
+ let canonicalWorkspaceRoot = workspaceRoot;
95
+ try {
96
+ canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot);
97
+ } catch {
98
+ canonicalWorkspaceRoot = workspaceRoot;
99
+ }
100
+
101
+ const slugSource = path.basename(workspaceRoot) || "workspace";
102
+ const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
103
+ const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16);
104
+ const pluginDataDir = process.env[PLUGIN_DATA_ENV];
105
+ const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
106
+ return path.join(stateRoot, `${slug}-${hash}`);
107
+ }
108
+
109
+ export function resolveStateFile(cwd) {
110
+ return path.join(resolveStateDir(cwd), STATE_FILE_NAME);
111
+ }
112
+
113
+ export function resolveJobsDir(cwd) {
114
+ return path.join(resolveStateDir(cwd), JOBS_DIR_NAME);
115
+ }
116
+
117
+ export function ensureStateDir(cwd) {
118
+ fs.mkdirSync(resolveJobsDir(cwd), { recursive: true, mode: 0o700 });
119
+ }
120
+
121
+ export function loadState(cwd) {
122
+ const stateFile = resolveStateFile(cwd);
123
+ if (!fs.existsSync(stateFile)) {
124
+ return defaultState();
125
+ }
126
+
127
+ try {
128
+ const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8"));
129
+ return {
130
+ ...defaultState(),
131
+ ...parsed,
132
+ config: {
133
+ ...defaultState().config,
134
+ ...(parsed.config ?? {})
135
+ },
136
+ jobs: Array.isArray(parsed.jobs) ? parsed.jobs : []
137
+ };
138
+ } catch {
139
+ return defaultState();
140
+ }
141
+ }
142
+
143
+ function pruneJobs(jobs) {
144
+ return [...jobs]
145
+ .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")))
146
+ .slice(0, MAX_JOBS);
147
+ }
148
+
149
+ function removeFileIfExists(filePath) {
150
+ if (filePath && fs.existsSync(filePath)) {
151
+ fs.unlinkSync(filePath);
152
+ }
153
+ }
154
+
155
+ export function saveState(cwd, state) {
156
+ const previousJobs = loadState(cwd).jobs;
157
+ ensureStateDir(cwd);
158
+ const nextJobs = pruneJobs(state.jobs ?? []);
159
+ const nextState = {
160
+ version: STATE_VERSION,
161
+ config: {
162
+ ...defaultState().config,
163
+ ...(state.config ?? {})
164
+ },
165
+ jobs: nextJobs
166
+ };
167
+
168
+ const retainedIds = new Set(nextJobs.map((job) => job.id));
169
+ for (const job of previousJobs) {
170
+ if (retainedIds.has(job.id)) {
171
+ continue;
172
+ }
173
+ removeJobFile(resolveJobFile(cwd, job.id));
174
+ if (job.logFile && path.dirname(path.resolve(job.logFile)) === path.resolve(resolveJobsDir(cwd))) {
175
+ removeFileIfExists(job.logFile);
176
+ }
177
+ }
178
+
179
+ atomicWrite(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`);
180
+ return nextState;
181
+ }
182
+
183
+ export function updateState(cwd, mutate) {
184
+ return withStateLock(cwd, () => {
185
+ const state = loadState(cwd);
186
+ mutate(state);
187
+ return saveState(cwd, state);
188
+ });
189
+ }
190
+
191
+ export function generateJobId(prefix = "job") {
192
+ const random = Math.random().toString(36).slice(2, 8);
193
+ return `${prefix}-${Date.now().toString(36)}-${random}`;
194
+ }
195
+
196
+ export function upsertJob(cwd, jobPatch) {
197
+ return updateState(cwd, (state) => {
198
+ const timestamp = nowIso();
199
+ const existingIndex = state.jobs.findIndex((job) => job.id === jobPatch.id);
200
+ if (existingIndex === -1) {
201
+ state.jobs.unshift({
202
+ createdAt: timestamp,
203
+ updatedAt: timestamp,
204
+ ...jobPatch
205
+ });
206
+ return;
207
+ }
208
+ state.jobs[existingIndex] = {
209
+ ...state.jobs[existingIndex],
210
+ ...jobPatch,
211
+ updatedAt: timestamp
212
+ };
213
+ });
214
+ }
215
+
216
+ export function listJobs(cwd) {
217
+ return loadState(cwd).jobs;
218
+ }
219
+
220
+ export function setConfig(cwd, key, value) {
221
+ return updateState(cwd, (state) => {
222
+ state.config = {
223
+ ...state.config,
224
+ [key]: value
225
+ };
226
+ });
227
+ }
228
+
229
+ export function getConfig(cwd) {
230
+ return loadState(cwd).config;
231
+ }
232
+
233
+ export function writeJobFile(cwd, jobId, payload) {
234
+ ensureStateDir(cwd);
235
+ const jobFile = resolveJobFile(cwd, jobId);
236
+ atomicWrite(jobFile, `${JSON.stringify(payload, null, 2)}\n`);
237
+ return jobFile;
238
+ }
239
+
240
+ export function readJobFile(jobFile) {
241
+ return JSON.parse(fs.readFileSync(jobFile, "utf8"));
242
+ }
243
+
244
+ function removeJobFile(jobFile) {
245
+ if (fs.existsSync(jobFile)) {
246
+ fs.unlinkSync(jobFile);
247
+ }
248
+ }
249
+
250
+ export function resolveJobLogFile(cwd, jobId) {
251
+ ensureStateDir(cwd);
252
+ return path.join(resolveJobsDir(cwd), `${jobId}.log`);
253
+ }
254
+
255
+ export function resolveJobFile(cwd, jobId) {
256
+ ensureStateDir(cwd);
257
+ return path.join(resolveJobsDir(cwd), `${jobId}.json`);
258
+ }