dsh-plugin-cc 0.1.0 → 0.2.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 +2 -2
- package/README.md +31 -15
- package/package.json +2 -2
- package/plugins/dsh/.claude-plugin/plugin.json +1 -1
- package/plugins/dsh/CHANGELOG.md +11 -0
- package/plugins/dsh/agents/dsh-rescue.md +1 -1
- package/plugins/dsh/commands/plan.md +7 -7
- package/plugins/dsh/commands/review-plan.md +4 -4
- package/plugins/dsh/scripts/dsh-companion.mjs +39 -8
- package/plugins/dsh/scripts/lib/args.mjs +19 -2
- package/plugins/dsh/scripts/lib/plans.mjs +60 -0
- package/plugins/dsh/scripts/lib/state.mjs +75 -3
- package/plugins/dsh/scripts/session-lifecycle-hook.mjs +45 -2
- package/plugins/dsh/skills/dsh-cli-runtime/SKILL.md +2 -2
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"owner": { "name": "dsh-plugin-cc contributors" },
|
|
4
4
|
"metadata": {
|
|
5
5
|
"description": "Unofficial Claude Code plugin for DeepSeek Harness (dsh).",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.2.0"
|
|
7
7
|
},
|
|
8
8
|
"plugins": [
|
|
9
9
|
{
|
|
10
10
|
"name": "dsh",
|
|
11
11
|
"description": "Use DeepSeek Harness from Claude Code to plan, review plans and delegate tasks.",
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.2.0",
|
|
13
13
|
"author": { "name": "dsh-plugin-cc contributors" },
|
|
14
14
|
"source": "./plugins/dsh"
|
|
15
15
|
}
|
package/README.md
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# dsh-plugin-cc
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-plugin-cc)
|
|
4
|
+
|
|
3
5
|
Use [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) from inside Claude Code to plan work, review plans and hand tasks to dsh.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
This plugin is for Claude Code users who want an easy way to start using dsh from the workflow they already have.
|
|
8
|
+
|
|
9
|
+
## What You Get
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
- `/dsh:plan` for a read-only implementation plan written by dsh
|
|
12
|
+
- `/dsh:review-plan` to check a plan against the real code
|
|
13
|
+
- `/dsh:rescue`, `/dsh:status`, `/dsh:result` and `/dsh:cancel` to delegate work and manage background jobs
|
|
8
14
|
|
|
9
15
|
## Requirements
|
|
10
16
|
|
|
@@ -15,19 +21,21 @@ The plugin follows the layout of [codex-plugin-cc](https://github.com/openai/cod
|
|
|
15
21
|
## Install
|
|
16
22
|
|
|
17
23
|
```
|
|
18
|
-
/plugin marketplace add
|
|
24
|
+
/plugin marketplace add TrungyuD/dsh-plugin-cc
|
|
19
25
|
/plugin install dsh@dsh-plugin-cc
|
|
20
26
|
/reload-plugins
|
|
21
27
|
/dsh:setup
|
|
22
28
|
```
|
|
23
29
|
|
|
30
|
+
The package is also published on npm as [`dsh-plugin-cc`](https://www.npmjs.com/package/dsh-plugin-cc) (`npm i dsh-plugin-cc`, `yarn add dsh-plugin-cc` or `pnpm add dsh-plugin-cc`). That only downloads the files into `node_modules`; it does not register the plugin with Claude Code. Install it with the `/plugin` commands above.
|
|
31
|
+
|
|
24
32
|
`/dsh:setup` checks Node, dsh and its version, and runs a one-line read-only smoke prompt to confirm you are logged in. If dsh is missing it offers to install it.
|
|
25
33
|
|
|
26
34
|
## Commands
|
|
27
35
|
|
|
28
36
|
| Command | What it does |
|
|
29
37
|
|---|---|
|
|
30
|
-
| `/dsh:plan <what to plan>` | dsh explores the repository and writes an implementation plan.
|
|
38
|
+
| `/dsh:plan <what to plan>` | dsh explores the repository and writes an implementation plan, which is saved as `plans/<YYMMDD-HHmm>-<slug>/plan.md`. dsh itself is read-only. |
|
|
31
39
|
| `/dsh:review-plan [path] [focus]` | dsh checks a plan against the real code and answers with `Verdict: approve`, `needs-changes` or `reject`, then findings. Read-only. |
|
|
32
40
|
| `/dsh:rescue [--write\|--read-only] [--resume\|--fresh] <task>` | Hands a debugging or implementation task to dsh through the `dsh-rescue` subagent. |
|
|
33
41
|
| `/dsh:status [job-id]` | Lists active and recent jobs for this repository. |
|
|
@@ -37,6 +45,15 @@ The plugin follows the layout of [codex-plugin-cc](https://github.com/openai/cod
|
|
|
37
45
|
|
|
38
46
|
`/dsh:plan` and `/dsh:review-plan` take `--wait` or `--background`. Without either, Claude asks once and recommends background. `/dsh:rescue` takes the same flags. All of them take `--model flash|pro|<name>`.
|
|
39
47
|
|
|
48
|
+
### Plan
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
/dsh:plan add a --version flag to the CLI
|
|
52
|
+
/dsh:plan --model pro --wait split the session code into its own module
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
dsh stays read-only while it explores. When it finishes, the plugin saves the plan as `plans/<YYMMDD-HHmm>-<slug>/plan.md` in the repository and prints `Saved plan: <path>`. A run that fails or is cancelled saves nothing. `/dsh:review-plan` with no path then offers that plan first. If you track `plans/` in git, each run leaves a new untracked file.
|
|
56
|
+
|
|
40
57
|
### Review a plan
|
|
41
58
|
|
|
42
59
|
```
|
|
@@ -61,8 +78,8 @@ The default is `deepseek-flash`, dsh's own default, so nothing is overridden unl
|
|
|
61
78
|
|
|
62
79
|
## Permission model
|
|
63
80
|
|
|
64
|
-
- `/dsh:plan` and `/dsh:review-plan` always run read-only. The sandbox denies file writes, and escalation requests fail because headless dsh has no approval channel.
|
|
65
|
-
- `/dsh:rescue` can write by default
|
|
81
|
+
- `/dsh:plan` and `/dsh:review-plan` always run read-only for dsh. The sandbox denies file writes, and escalation requests fail because headless dsh has no approval channel. `/dsh:plan` writes one file, the plan, after dsh has finished.
|
|
82
|
+
- `/dsh:rescue` can write by default. Ask for diagnosis or research only, or pass `--read-only`, to prevent edits.
|
|
66
83
|
- A resumed rescue session keeps the mode it was created with. Asking for a different mode on resume fails with a message to start a new session with `--fresh`.
|
|
67
84
|
- "Read-only" protects your workspace. dsh still writes its own sessions and profile under `~/.dsh/`.
|
|
68
85
|
- The plugin never uses dsh's `never` approval policy, because that auto-approves.
|
|
@@ -71,7 +88,7 @@ The default is `deepseek-flash`, dsh's own default, so nothing is overridden unl
|
|
|
71
88
|
|
|
72
89
|
Every run is a one-shot `dsh --profile headless --json` process, and each one leaves a session under `~/.dsh/sessions/`. The footer of each result names the session, for example `dsh session: session-… · model: deepseek-flash · mode: read-only`. Continue a session in the terminal with `dsh tui --resume <session-id>`.
|
|
73
90
|
|
|
74
|
-
Job records live in the plugin data directory, scoped to the repository and to the Claude session. Ending the Claude session cancels that session's running jobs
|
|
91
|
+
Job records live in the plugin data directory, scoped to the repository and to the Claude session. Ending the Claude session cancels that session's running jobs, including jobs it started in other directories with `--cwd`.
|
|
75
92
|
|
|
76
93
|
## Limits
|
|
77
94
|
|
|
@@ -80,16 +97,15 @@ Job records live in the plugin data directory, scoped to the repository and to t
|
|
|
80
97
|
- No `--effort` option, and no stop-time review gate.
|
|
81
98
|
- The plugin only knows the permission mode it started a session with. If you change a session's mode yourself in `dsh tui --resume`, the plugin's footer and its `--read-only` check no longer reflect it.
|
|
82
99
|
- Foreground runs are limited by Claude Code's Bash timeout (the commands ask for 10 minutes). Use `--background` for longer work.
|
|
83
|
-
- Quote and backslash characters in request text are normalized when the arguments are split.
|
|
84
|
-
- Ending a Claude session only cancels jobs started from that session's starting directory; jobs started with `--cwd` elsewhere are not cleaned up.
|
|
85
|
-
|
|
86
|
-
## Development
|
|
87
100
|
|
|
88
|
-
|
|
89
|
-
npm test
|
|
90
|
-
```
|
|
101
|
+
## Help and contributing
|
|
91
102
|
|
|
92
|
-
|
|
103
|
+
- [docs/troubleshooting.md](docs/troubleshooting.md) covers common problems.
|
|
104
|
+
- [SUPPORT.md](SUPPORT.md) explains where to ask for help and what to include.
|
|
105
|
+
- [SECURITY.md](SECURITY.md) explains how to report a vulnerability privately.
|
|
106
|
+
- [CONTRIBUTING.md](CONTRIBUTING.md) covers development, tests and releases.
|
|
107
|
+
- [docs/dsh-compat.md](docs/dsh-compat.md) lists everything the plugin relies on in dsh.
|
|
108
|
+
- [plugins/dsh/CHANGELOG.md](plugins/dsh/CHANGELOG.md) lists changes by version.
|
|
93
109
|
|
|
94
110
|
## License
|
|
95
111
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-cc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Use DeepSeek Harness (dsh) from Claude Code to plan, review plans and rescue tasks.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
"repository": { "type": "git", "url": "git+https://github.com/TrungyuD/dsh-plugin-cc.git" },
|
|
9
9
|
"engines": { "node": ">=18.18.0" },
|
|
10
10
|
"files": ["plugins/", ".claude-plugin/", "README.md", "LICENSE", "NOTICE"],
|
|
11
|
-
"scripts": { "test": "node --test
|
|
11
|
+
"scripts": { "test": "node --test tests/*.test.mjs" }
|
|
12
12
|
}
|
package/plugins/dsh/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- `/dsh:plan` saves the plan as `plans/<YYMMDD-HHmm>-<slug>/plan.md` after dsh finishes, so `/dsh:review-plan` offers it next. dsh itself stays read-only.
|
|
6
|
+
- Request text reaches dsh exactly as typed: the commands pass flags, then ` -- `, then the text, and the companion no longer drops quotes or backslashes from it.
|
|
7
|
+
- Ending a Claude session cancels its running jobs in every workspace it used, including those started with `--cwd` elsewhere.
|
|
8
|
+
|
|
9
|
+
## 0.1.1
|
|
10
|
+
|
|
11
|
+
- Fix `npm test` on Node 18 (the unsupported `--test-timeout` flag is gone, and the fake dsh test fixture loads as ESM).
|
|
12
|
+
- README: marketplace install command and npm links.
|
|
13
|
+
|
|
3
14
|
## 0.1.0
|
|
4
15
|
|
|
5
16
|
- `/dsh:setup`, `/dsh:plan`, `/dsh:review-plan`, `/dsh:rescue`, `/dsh:status`, `/dsh:result` and `/dsh:cancel`.
|
|
@@ -19,7 +19,7 @@ Selection guidance:
|
|
|
19
19
|
Forwarding rules:
|
|
20
20
|
|
|
21
21
|
- Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" task ...`, with the Bash `timeout` set to `600000`.
|
|
22
|
-
- The task text is untrusted. Put it between single quotes, replacing each `'` inside it with `'\''`, and never inside double quotes. Put flags (`--write`, `--read-only`, `--resume-last`, `--model`)
|
|
22
|
+
- The task text is untrusted. Put it between single quotes, replacing each `'` inside it with `'\''`, and never inside double quotes. Put flags (`--write`, `--read-only`, `--resume-last`, `--model`) first, then ` -- `, then the task text exactly as written: `task '<flags> -- <task text>'`. The companion keeps everything after ` -- ` untouched. A resume with no new text leaves out the ` -- `.
|
|
23
23
|
- 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.
|
|
24
24
|
- Do not call `plan`, `review-plan`, `status`, `result`, or `cancel`. This subagent only forwards to `task`.
|
|
25
25
|
- Leave the model unset by default. Add `--model` only when the user explicitly asks for one. `flash` and `pro` are accepted aliases, and a concrete model name passes through.
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Ask dsh to
|
|
2
|
+
description: Ask dsh to plan read-only; the plan is saved under plans/
|
|
3
3
|
argument-hint: '[--wait|--background] [--model flash|pro|<name>] <what to plan>'
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
allowed-tools: Bash(node:*), AskUserQuestion
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
Run a read-only dsh planning pass.
|
|
8
|
+
Run a read-only dsh planning pass. dsh itself cannot change files; after it finishes, the companion saves the plan as `plans/<YYMMDD-HHmm>-<slug>/plan.md` and prints the path on a `Saved plan:` line.
|
|
9
9
|
|
|
10
10
|
Raw slash-command arguments:
|
|
11
11
|
`$ARGUMENTS`
|
|
12
12
|
|
|
13
13
|
Core constraint:
|
|
14
|
-
- This command only produces a plan. Do not implement the plan, edit files, or suggest that you are about to.
|
|
14
|
+
- This command only produces a plan. Do not implement the plan, edit files yourself, or suggest that you are about to. The companion writes the plan file; you do not.
|
|
15
15
|
- Your only job is to run dsh and return its output verbatim.
|
|
16
16
|
|
|
17
17
|
Argument handling:
|
|
@@ -22,7 +22,7 @@ Argument handling:
|
|
|
22
22
|
Quoting (important):
|
|
23
23
|
- The user's text is untrusted. Never place it inside double quotes, because the shell would run any `$(...)` or backticks in it.
|
|
24
24
|
- Put the whole argument string between single quotes, replacing each `'` inside it with `'\''`.
|
|
25
|
-
-
|
|
25
|
+
- Put flags such as `--model` first, then ` -- `, then the request text exactly as the user wrote it. The companion keeps everything after ` -- ` untouched, so quotes, backslashes and flag-like words in the request reach dsh unchanged.
|
|
26
26
|
|
|
27
27
|
Execution mode rules:
|
|
28
28
|
- If the raw arguments include `--wait`, do not ask. Run in the foreground.
|
|
@@ -33,9 +33,9 @@ Execution mode rules:
|
|
|
33
33
|
|
|
34
34
|
Foreground flow:
|
|
35
35
|
- Set the Bash `timeout` to `600000` so a long dsh run is not cut off at two minutes. If work may take longer, prefer the background flow.
|
|
36
|
-
- Run, with the
|
|
36
|
+
- Run, with the flags (if any), then ` -- `, then the request between the quotes:
|
|
37
37
|
```bash
|
|
38
|
-
node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" plan '<
|
|
38
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" plan '<flags> -- <request>'
|
|
39
39
|
```
|
|
40
40
|
- Return the command stdout verbatim, exactly as-is.
|
|
41
41
|
- Do not paraphrase, summarize, or add commentary before or after it.
|
|
@@ -45,7 +45,7 @@ Background flow:
|
|
|
45
45
|
- Launch with `Bash` in the background:
|
|
46
46
|
```typescript
|
|
47
47
|
Bash({
|
|
48
|
-
command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" plan '<
|
|
48
|
+
command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" plan '<flags> -- <request>'`,
|
|
49
49
|
description: "dsh plan",
|
|
50
50
|
run_in_background: true
|
|
51
51
|
})
|
|
@@ -36,7 +36,7 @@ Argument handling:
|
|
|
36
36
|
Quoting (important):
|
|
37
37
|
- The user's text is untrusted. Never place it inside double quotes, because the shell would run any `$(...)` or backticks in it.
|
|
38
38
|
- Put the whole argument string between single quotes, replacing each `'` inside it with `'\''`.
|
|
39
|
-
-
|
|
39
|
+
- Put flags such as `--model` first, then the plan path. Add ` -- ` and the focus text only when there is focus text; the companion keeps everything after ` -- ` untouched. With no focus, leave out the ` -- `.
|
|
40
40
|
|
|
41
41
|
Execution mode rules:
|
|
42
42
|
- If the raw arguments include `--wait`, do not ask. Run in the foreground.
|
|
@@ -48,9 +48,9 @@ Execution mode rules:
|
|
|
48
48
|
|
|
49
49
|
Foreground flow:
|
|
50
50
|
- Set the Bash `timeout` to `600000` so a long dsh run is not cut off at two minutes. If work may take longer, prefer the background flow.
|
|
51
|
-
- Run, with the resolved path, then the focus text, between the quotes:
|
|
51
|
+
- Run, with the flags (if any), the resolved path, then ` -- ` and the focus text (no ` -- ` without focus), between the quotes:
|
|
52
52
|
```bash
|
|
53
|
-
node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" review-plan '<
|
|
53
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" review-plan '<flags> <plan-path> -- <focus>'
|
|
54
54
|
```
|
|
55
55
|
- Return the command stdout verbatim, exactly as-is.
|
|
56
56
|
- Do not paraphrase, summarize, or add commentary before or after it.
|
|
@@ -60,7 +60,7 @@ Background flow:
|
|
|
60
60
|
- Launch with `Bash` in the background:
|
|
61
61
|
```typescript
|
|
62
62
|
Bash({
|
|
63
|
-
command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" review-plan '<
|
|
63
|
+
command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" review-plan '<flags> <plan-path> -- <focus>'`,
|
|
64
64
|
description: "dsh review-plan",
|
|
65
65
|
run_in_background: true
|
|
66
66
|
})
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
resolveResultJob,
|
|
23
23
|
resolveResumableTask
|
|
24
24
|
} from "./lib/job-control.mjs";
|
|
25
|
-
import { collectPlanFiles, findLatestPlanDir } from "./lib/plans.mjs";
|
|
25
|
+
import { collectPlanFiles, findLatestPlanDir, savePlanFile } from "./lib/plans.mjs";
|
|
26
26
|
import { binaryAvailable, processCommandIncludes, terminateProcessTree } from "./lib/process.mjs";
|
|
27
27
|
import { interpolateTemplate, loadPromptTemplate } from "./lib/prompts.mjs";
|
|
28
28
|
import {
|
|
@@ -32,13 +32,14 @@ import {
|
|
|
32
32
|
renderStatusReport,
|
|
33
33
|
renderStoredJobResult
|
|
34
34
|
} from "./lib/render.mjs";
|
|
35
|
-
import { generateJobId, upsertJob, withStateLock, writeJobFile } from "./lib/state.mjs";
|
|
35
|
+
import { generateJobId, registerSessionWorkspace, upsertJob, withStateLock, writeJobFile } from "./lib/state.mjs";
|
|
36
36
|
import {
|
|
37
37
|
appendLogLine,
|
|
38
38
|
createJobLogFile,
|
|
39
39
|
createJobProgressUpdater,
|
|
40
40
|
createJobRecord,
|
|
41
41
|
createProgressReporter,
|
|
42
|
+
isJobCancelled,
|
|
42
43
|
nowIso,
|
|
43
44
|
recordDshPid,
|
|
44
45
|
runTrackedJob
|
|
@@ -89,21 +90,24 @@ function outputCommandResult(payload, rendered, asJson) {
|
|
|
89
90
|
outputResult(asJson ? payload : rendered, asJson);
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
// A single argv entry is the raw slash-command string: split it shell-style, but keep everything
|
|
94
|
+
// after an unquoted `--` as one verbatim text. Several argv entries are already split.
|
|
92
95
|
function normalizeArgv(argv) {
|
|
93
96
|
if (argv.length === 1) {
|
|
94
97
|
const [raw] = argv;
|
|
95
98
|
if (!raw || !raw.trim()) {
|
|
96
|
-
return [];
|
|
99
|
+
return { tokens: [], verbatim: null };
|
|
97
100
|
}
|
|
98
101
|
return splitRawArgumentString(raw);
|
|
99
102
|
}
|
|
100
|
-
return argv;
|
|
103
|
+
return { tokens: argv, verbatim: null };
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
// --wait and --background are handled by the slash commands. They are accepted and ignored here
|
|
104
107
|
// so a stray flag never turns into prompt text.
|
|
105
108
|
function parseCommandInput(argv, config = {}) {
|
|
106
|
-
|
|
109
|
+
const { tokens, verbatim } = normalizeArgv(argv);
|
|
110
|
+
const parsed = parseArgs(tokens, {
|
|
107
111
|
...config,
|
|
108
112
|
booleanOptions: [...(config.booleanOptions ?? []), "background", "wait"],
|
|
109
113
|
aliasMap: {
|
|
@@ -111,6 +115,11 @@ function parseCommandInput(argv, config = {}) {
|
|
|
111
115
|
...(config.aliasMap ?? {})
|
|
112
116
|
}
|
|
113
117
|
});
|
|
118
|
+
// The verbatim text never goes through option parsing, so text such as `--json` stays text.
|
|
119
|
+
if (verbatim) {
|
|
120
|
+
parsed.positionals.push(verbatim);
|
|
121
|
+
}
|
|
122
|
+
return parsed;
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
function canonicalCwd(cwd) {
|
|
@@ -206,9 +215,13 @@ function phaseForEvent(event) {
|
|
|
206
215
|
|
|
207
216
|
/**
|
|
208
217
|
* Runs one dsh headless process under job tracking, in the foreground of this process.
|
|
209
|
-
* `summarize(finalText)` produces the one-line job summary.
|
|
218
|
+
* `summarize(finalText)` produces the one-line job summary. `finalize(result)` may return
|
|
219
|
+
* `{ payload, renderedSuffix }` extras that are stored with the job result and shown after the output.
|
|
210
220
|
*/
|
|
211
|
-
async function executeRun({ job, prompt, permissionMode, model, sessionId, summarize, asJson }) {
|
|
221
|
+
async function executeRun({ job, prompt, permissionMode, model, sessionId, summarize, finalize, asJson }) {
|
|
222
|
+
if (job.sessionId) {
|
|
223
|
+
registerSessionWorkspace(job.sessionId, job.workspaceRoot);
|
|
224
|
+
}
|
|
212
225
|
const logFile = createJobLogFile(job.workspaceRoot, job.id, job.title);
|
|
213
226
|
const jobWithLog = { ...job, logFile };
|
|
214
227
|
upsertJob(job.workspaceRoot, jobWithLog);
|
|
@@ -259,7 +272,13 @@ async function executeRun({ job, prompt, permissionMode, model, sessionId, summa
|
|
|
259
272
|
}
|
|
260
273
|
}
|
|
261
274
|
});
|
|
262
|
-
|
|
275
|
+
const extras = finalize?.(result);
|
|
276
|
+
return {
|
|
277
|
+
...result,
|
|
278
|
+
payload: { ...result.payload, ...extras?.payload },
|
|
279
|
+
rendered: `${result.rendered}${extras?.renderedSuffix ?? ""}`,
|
|
280
|
+
summary: summarize(result.payload.finalText, result)
|
|
281
|
+
};
|
|
263
282
|
},
|
|
264
283
|
{ logFile }
|
|
265
284
|
);
|
|
@@ -322,6 +341,18 @@ async function handlePlan(argv) {
|
|
|
322
341
|
permissionMode: READ_ONLY,
|
|
323
342
|
model,
|
|
324
343
|
summarize: (finalText) => shorten(firstMeaningfulLine(finalText, request)),
|
|
344
|
+
// dsh stays read-only; the plan file is written here, after dsh has exited.
|
|
345
|
+
finalize: (result) => {
|
|
346
|
+
if (result.exitStatus !== 0 || !result.payload.finalText.trim() || isJobCancelled(workspaceRoot, job.id)) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const planFile = savePlanFile(workspaceRoot, request, result.payload.finalText);
|
|
351
|
+
return { payload: { planFile }, renderedSuffix: `Saved plan: ${planFile}\n` };
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return { payload: {}, renderedSuffix: `Plan not saved: ${error instanceof Error ? error.message : String(error)}\n` };
|
|
354
|
+
}
|
|
355
|
+
},
|
|
325
356
|
asJson: options.json
|
|
326
357
|
});
|
|
327
358
|
}
|
|
@@ -75,13 +75,21 @@ export function parseArgs(argv, config = {}) {
|
|
|
75
75
|
return { options, positionals };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Splits one raw argument string like a shell would, except that an unquoted standalone `--` ends
|
|
80
|
+
* the splitting: everything after it comes back untouched as `verbatim`, so request text keeps its
|
|
81
|
+
* quotes and backslashes. `verbatim` is null when there is no such `--`.
|
|
82
|
+
*/
|
|
78
83
|
export function splitRawArgumentString(raw) {
|
|
79
84
|
const tokens = [];
|
|
80
85
|
let current = "";
|
|
81
86
|
let quote = null;
|
|
82
87
|
let escaping = false;
|
|
88
|
+
let wordStart = true;
|
|
89
|
+
|
|
90
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
91
|
+
const character = raw[index];
|
|
83
92
|
|
|
84
|
-
for (const character of raw) {
|
|
85
93
|
if (escaping) {
|
|
86
94
|
current += character;
|
|
87
95
|
escaping = false;
|
|
@@ -90,6 +98,7 @@ export function splitRawArgumentString(raw) {
|
|
|
90
98
|
|
|
91
99
|
if (character === "\\") {
|
|
92
100
|
escaping = true;
|
|
101
|
+
wordStart = false;
|
|
93
102
|
continue;
|
|
94
103
|
}
|
|
95
104
|
|
|
@@ -104,6 +113,7 @@ export function splitRawArgumentString(raw) {
|
|
|
104
113
|
|
|
105
114
|
if (character === "'" || character === "\"") {
|
|
106
115
|
quote = character;
|
|
116
|
+
wordStart = false;
|
|
107
117
|
continue;
|
|
108
118
|
}
|
|
109
119
|
|
|
@@ -112,10 +122,17 @@ export function splitRawArgumentString(raw) {
|
|
|
112
122
|
tokens.push(current);
|
|
113
123
|
current = "";
|
|
114
124
|
}
|
|
125
|
+
wordStart = true;
|
|
115
126
|
continue;
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
if (wordStart && character === "-" && raw[index + 1] === "-" && (index + 2 === raw.length || /\s/.test(raw[index + 2]))) {
|
|
130
|
+
// The one whitespace character that separates `--` from the text is not part of the text.
|
|
131
|
+
return { tokens, verbatim: raw.slice(index + 2).replace(/^\s/, "") };
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
current += character;
|
|
135
|
+
wordStart = false;
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
if (escaping) {
|
|
@@ -126,5 +143,5 @@ export function splitRawArgumentString(raw) {
|
|
|
126
143
|
tokens.push(current);
|
|
127
144
|
}
|
|
128
145
|
|
|
129
|
-
return tokens;
|
|
146
|
+
return { tokens, verbatim: null };
|
|
130
147
|
}
|
|
@@ -68,3 +68,63 @@ export function collectPlanFiles(absolutePath) {
|
|
|
68
68
|
}
|
|
69
69
|
return [planFile, ...listPhaseFiles(absolutePath).map((name) => path.join(absolutePath, name))];
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
const SLUG_LIMIT = 40;
|
|
73
|
+
const HEADING_LIMIT = 120;
|
|
74
|
+
|
|
75
|
+
function pad(value) {
|
|
76
|
+
return String(value).padStart(2, "0");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function slugify(text) {
|
|
80
|
+
// NFKD has no decomposition for the Vietnamese d with stroke, so map it first.
|
|
81
|
+
const slug = String(text ?? "")
|
|
82
|
+
.replace(/đ/g, "d")
|
|
83
|
+
.replace(/Đ/g, "D")
|
|
84
|
+
.normalize("NFKD")
|
|
85
|
+
.replace(/[̀-ͯ]/g, "")
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
88
|
+
.replace(/^-+|-+$/g, "");
|
|
89
|
+
if (slug.length <= SLUG_LIMIT) {
|
|
90
|
+
return slug;
|
|
91
|
+
}
|
|
92
|
+
const cut = slug.slice(0, SLUG_LIMIT + 1);
|
|
93
|
+
const boundary = cut.lastIndexOf("-");
|
|
94
|
+
return (boundary > 0 ? cut.slice(0, boundary) : cut.slice(0, SLUG_LIMIT)).replace(/-+$/, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Name of a new plan directory, `YYMMDD-HHmm-<slug>`, in the local time of `date`. */
|
|
98
|
+
export function planDirName(request, date = new Date()) {
|
|
99
|
+
const stamp = `${pad(date.getFullYear() % 100)}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
|
|
100
|
+
return `${stamp}-${slugify(request) || "dsh-plan"}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Writes `text` as `<workspaceRoot>/plans/<planDirName>/plan.md` and returns that path relative to
|
|
105
|
+
* the workspace. The directory is created exclusively, so two saves in the same minute with the
|
|
106
|
+
* same request get `-2`, `-3`, ... instead of sharing a directory.
|
|
107
|
+
*/
|
|
108
|
+
export function savePlanFile(workspaceRoot, request, text, date = new Date()) {
|
|
109
|
+
const plansDir = path.join(workspaceRoot, "plans");
|
|
110
|
+
fs.mkdirSync(plansDir, { recursive: true });
|
|
111
|
+
|
|
112
|
+
const base = planDirName(request, date);
|
|
113
|
+
let name = base;
|
|
114
|
+
for (let attempt = 2; ; attempt += 1) {
|
|
115
|
+
try {
|
|
116
|
+
fs.mkdirSync(path.join(plansDir, name));
|
|
117
|
+
break;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error?.code !== "EEXIST") {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
name = `${base}-${attempt}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const title = String(request ?? "").trim().replace(/\s+/g, " ").slice(0, HEADING_LIMIT);
|
|
127
|
+
const file = path.join(plansDir, name, "plan.md");
|
|
128
|
+
fs.writeFileSync(file, `# Plan: ${title}\n\n${String(text).trimEnd()}\n`, "utf8");
|
|
129
|
+
return path.join("plans", name, "plan.md");
|
|
130
|
+
}
|
|
@@ -10,6 +10,7 @@ const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
|
|
|
10
10
|
const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "dsh-companion");
|
|
11
11
|
const STATE_FILE_NAME = "state.json";
|
|
12
12
|
const JOBS_DIR_NAME = "jobs";
|
|
13
|
+
const SESSIONS_DIR_NAME = "sessions";
|
|
13
14
|
const MAX_JOBS = 50;
|
|
14
15
|
const LOCK_DIR_NAME = ".lock";
|
|
15
16
|
const LOCK_STALE_MS = 10000;
|
|
@@ -89,6 +90,11 @@ function defaultState() {
|
|
|
89
90
|
};
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
function resolveStateRoot() {
|
|
94
|
+
const pluginDataDir = process.env[PLUGIN_DATA_ENV];
|
|
95
|
+
return pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
export function resolveStateDir(cwd) {
|
|
93
99
|
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
94
100
|
let canonicalWorkspaceRoot = workspaceRoot;
|
|
@@ -101,9 +107,75 @@ export function resolveStateDir(cwd) {
|
|
|
101
107
|
const slugSource = path.basename(workspaceRoot) || "workspace";
|
|
102
108
|
const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
|
|
103
109
|
const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
return path.join(resolveStateRoot(), `${slug}-${hash}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Session index: one marker per (Claude session, workspace), so SessionEnd can find every
|
|
115
|
+
// workspace a session started jobs in, not only the directory it started in.
|
|
116
|
+
|
|
117
|
+
function resolveSessionIndexDir(sessionId) {
|
|
118
|
+
const hash = createHash("sha256").update(String(sessionId)).digest("hex").slice(0, 16);
|
|
119
|
+
return path.join(resolveStateRoot(), SESSIONS_DIR_NAME, hash);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function registerSessionWorkspace(sessionId, workspaceRoot) {
|
|
123
|
+
const dir = resolveSessionIndexDir(sessionId);
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
125
|
+
const marker = path.join(dir, `${path.basename(resolveStateDir(workspaceRoot))}.json`);
|
|
126
|
+
atomicWrite(marker, `${JSON.stringify({ workspaceRoot })}\n`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function listSessionWorkspaces(sessionId) {
|
|
130
|
+
const dir = resolveSessionIndexDir(sessionId);
|
|
131
|
+
let names;
|
|
132
|
+
try {
|
|
133
|
+
names = fs.readdirSync(dir);
|
|
134
|
+
} catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const roots = [];
|
|
138
|
+
for (const name of names) {
|
|
139
|
+
if (!name.endsWith(".json")) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const { workspaceRoot } = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
144
|
+
if (typeof workspaceRoot === "string" && workspaceRoot) {
|
|
145
|
+
roots.push(workspaceRoot);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// A half-written or foreign file is not a marker.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return roots;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function forgetSessionWorkspaces(sessionId) {
|
|
155
|
+
fs.rmSync(resolveSessionIndexDir(sessionId), { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Drops marker dirs of sessions that never reached SessionEnd. Every run rewrites its marker, which
|
|
159
|
+
// refreshes the dir mtime, so a session that is still running jobs keeps its markers.
|
|
160
|
+
export function pruneSessionIndex(maxAgeMs) {
|
|
161
|
+
const sessionsDir = path.join(resolveStateRoot(), SESSIONS_DIR_NAME);
|
|
162
|
+
let names;
|
|
163
|
+
try {
|
|
164
|
+
names = fs.readdirSync(sessionsDir);
|
|
165
|
+
} catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
const dir = path.join(sessionsDir, name);
|
|
171
|
+
try {
|
|
172
|
+
if (fs.statSync(dir).mtimeMs < cutoff) {
|
|
173
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Already gone or unreadable; the next prune retries.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
107
179
|
}
|
|
108
180
|
|
|
109
181
|
export function resolveStateFile(cwd) {
|
|
@@ -5,11 +5,24 @@ import process from "node:process";
|
|
|
5
5
|
|
|
6
6
|
import { CHILD_ENV } from "./lib/dsh.mjs";
|
|
7
7
|
import { processCommandIncludes, terminateProcessTree } from "./lib/process.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
forgetSessionWorkspaces,
|
|
10
|
+
listJobs,
|
|
11
|
+
listSessionWorkspaces,
|
|
12
|
+
pruneSessionIndex,
|
|
13
|
+
readJobFile,
|
|
14
|
+
resolveJobFile,
|
|
15
|
+
resolveStateDir,
|
|
16
|
+
resolveStateFile,
|
|
17
|
+
upsertJob,
|
|
18
|
+
withStateLock,
|
|
19
|
+
writeJobFile
|
|
20
|
+
} from "./lib/state.mjs";
|
|
9
21
|
import { nowIso, SESSION_ID_ENV } from "./lib/tracked-jobs.mjs";
|
|
10
22
|
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
|
|
11
23
|
|
|
12
24
|
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
|
|
25
|
+
const SESSION_INDEX_MAX_AGE_MS = 7 * 24 * 3600 * 1000;
|
|
13
26
|
|
|
14
27
|
function readHookInput() {
|
|
15
28
|
const raw = fs.readFileSync(0, "utf8").trim();
|
|
@@ -85,10 +98,40 @@ function cleanupSessionJobs(cwd, sessionId) {
|
|
|
85
98
|
function handleSessionStart(input) {
|
|
86
99
|
appendEnvVar(SESSION_ID_ENV, input.session_id);
|
|
87
100
|
appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
|
|
101
|
+
try {
|
|
102
|
+
pruneSessionIndex(SESSION_INDEX_MAX_AGE_MS);
|
|
103
|
+
} catch {
|
|
104
|
+
// Housekeeping must never fail a session start.
|
|
105
|
+
}
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
function handleSessionEnd(input) {
|
|
91
|
-
|
|
109
|
+
const sessionId = input.session_id || process.env[SESSION_ID_ENV];
|
|
110
|
+
if (!sessionId) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The starting directory plus every workspace this session ran a job in, one state dir each.
|
|
115
|
+
const roots = new Map();
|
|
116
|
+
for (const cwd of [input.cwd || process.cwd(), ...listSessionWorkspaces(sessionId)]) {
|
|
117
|
+
try {
|
|
118
|
+
roots.set(resolveStateDir(cwd), cwd);
|
|
119
|
+
} catch {
|
|
120
|
+
// A workspace that can no longer be resolved has nothing to clean.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
for (const cwd of roots.values()) {
|
|
126
|
+
try {
|
|
127
|
+
cleanupSessionJobs(cwd, sessionId);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
forgetSessionWorkspaces(sessionId);
|
|
134
|
+
}
|
|
92
135
|
}
|
|
93
136
|
|
|
94
137
|
function main() {
|
|
@@ -9,10 +9,10 @@ user-invocable: false
|
|
|
9
9
|
Use this skill only inside the `dsh:dsh-rescue` subagent.
|
|
10
10
|
|
|
11
11
|
Primary helper:
|
|
12
|
-
- `node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" task
|
|
12
|
+
- `node "${CLAUDE_PLUGIN_ROOT}/scripts/dsh-companion.mjs" task '<flags> -- <task text>'`
|
|
13
13
|
|
|
14
14
|
Execution rules:
|
|
15
|
-
- Set the Bash `timeout` to `600000` and put the
|
|
15
|
+
- Set the Bash `timeout` to `600000` and put the whole argument string between single quotes (escape each `'` as `'\''`): the flags first, then ` -- `, then the task text. The companion keeps the text after ` -- ` untouched.
|
|
16
16
|
- The rescue subagent is a forwarder, not an orchestrator. Its only job is to invoke `task` once and return that stdout unchanged.
|
|
17
17
|
- Prefer the helper over hand-rolled `dsh` command lines or any other Bash activity.
|
|
18
18
|
- Do not call `setup`, `plan`, `review-plan`, `status`, `result`, or `cancel` from `dsh:dsh-rescue`.
|