@lifenrir/dsh-external-task-context-patch 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib.mjs ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Helper functions for the patch installer: argument parsing, version/tag
3
+ * mapping, DSH home resolution, profile discovery, and install-record checks.
4
+ * These helpers are free of installer side effects so they can be unit tested.
5
+ *
6
+ * @module dsh-external-task-context-patch/lib
7
+ */
8
+
9
+ import { existsSync } from 'node:fs'
10
+ import { readdir } from 'node:fs/promises'
11
+ import { homedir } from 'node:os'
12
+ import { join, resolve } from 'node:path'
13
+
14
+ /** npm name of the provider package this patch targets. */
15
+ export const PROVIDER_PACKAGE_NAME = '@deepseek-ai/dsh-subagent-claude-code'
16
+
17
+ /** git tag used when the caller does not pass `--tag`. */
18
+ export const DEFAULT_TAG = 'dsh-v0.2.2-rc.1'
19
+
20
+ /** Official provider source files fetched or read from `--source`. */
21
+ export const PROVIDER_SRC_FILES = ['index.ts', 'run.ts', 'process.ts']
22
+
23
+ /** Patched source files copied from this package's `src/` directory. */
24
+ export const PATCHED_SRC_FILES = ['index.ts', 'run.ts', 'log.ts']
25
+
26
+ /** Directory name under `<DSH_HOME>/backups` that owns all patch records. */
27
+ export const BACKUP_DIR_NAME = 'subagent-claude-code-patch'
28
+
29
+ /** Relative path of the run-log directory created by the patched provider. */
30
+ export const RUN_LOG_RELATIVE_PATH = ['logs', 'subagent-claude-code']
31
+
32
+ const VALUE_OPTIONS = new Set(['--dsh-home', '--profile', '--tag', '--source'])
33
+ const FLAG_OPTIONS = new Set(['--dry-run', '--force', '--restore', '--yes', '--help', '-h'])
34
+
35
+ /**
36
+ * Parse installer CLI arguments.
37
+ * @param {readonly string[]} argv - process arguments without the executable and script.
38
+ * @returns {{ dshHome: string | undefined, profiles: string[], tag: string, source: string | undefined, dryRun: boolean, force: boolean, restore: boolean, yes: boolean, help: boolean }}
39
+ * @throws {Error} on an unknown option, a missing value, or an invalid profile name.
40
+ */
41
+ export function parseArgs(argv) {
42
+ const options = {
43
+ dshHome: undefined,
44
+ profiles: [],
45
+ tag: DEFAULT_TAG,
46
+ source: undefined,
47
+ dryRun: false,
48
+ force: false,
49
+ restore: false,
50
+ yes: false,
51
+ help: false,
52
+ }
53
+ for (let index = 0; index < argv.length; index += 1) {
54
+ const argument = argv[index]
55
+ if (FLAG_OPTIONS.has(argument)) {
56
+ switch (argument) {
57
+ case '--dry-run': options.dryRun = true; break
58
+ case '--force': options.force = true; break
59
+ case '--restore': options.restore = true; break
60
+ case '--yes': options.yes = true; break
61
+ default: options.help = true
62
+ }
63
+ continue
64
+ }
65
+
66
+ let name = argument
67
+ let value
68
+ const equals = argument.indexOf('=')
69
+ if (argument.startsWith('--') && equals !== -1) {
70
+ name = argument.slice(0, equals)
71
+ value = argument.slice(equals + 1)
72
+ }
73
+ if (!VALUE_OPTIONS.has(name)) {
74
+ throw new Error(`unknown option: ${argument}`)
75
+ }
76
+ if (value === undefined) {
77
+ index += 1
78
+ value = argv[index]
79
+ if (value === undefined || value.startsWith('--')) {
80
+ throw new Error(`missing value for ${name}`)
81
+ }
82
+ }
83
+ if (value.trim().length === 0) {
84
+ throw new Error(`empty value for ${name}`)
85
+ }
86
+ switch (name) {
87
+ case '--dsh-home': options.dshHome = value; break
88
+ case '--profile': options.profiles.push(assertProfileName(value)); break
89
+ case '--tag': options.tag = value; break
90
+ case '--source': options.source = value; break
91
+ default: throw new Error(`unknown option: ${name}`)
92
+ }
93
+ }
94
+ return options
95
+ }
96
+
97
+ /**
98
+ * Reject profile names that could escape `<DSH_HOME>/profiles`.
99
+ * @param {string} name - profile directory name.
100
+ * @returns {string} the unchanged name when valid.
101
+ */
102
+ export function assertProfileName(name) {
103
+ if (
104
+ name.length === 0
105
+ || name === '.'
106
+ || name === '..'
107
+ || name.includes('/')
108
+ || name.includes('\\')
109
+ ) {
110
+ throw new Error(`invalid profile name: ${name}`)
111
+ }
112
+ return name
113
+ }
114
+
115
+ /**
116
+ * Resolve the DeepSeek Harness home directory.
117
+ * @param {string | undefined} explicit - `--dsh-home` value, when supplied.
118
+ * @param {NodeJS.ProcessEnv} [env] - environment source.
119
+ * @param {string} [home] - operating-system home directory.
120
+ * @returns {string} absolute DSH home path.
121
+ * @throws {Error} when an explicit `--dsh-home` is blank.
122
+ */
123
+ export function resolveDshHome(explicit, env = process.env, home = homedir()) {
124
+ if (explicit !== undefined) {
125
+ if (explicit.trim().length === 0) {
126
+ throw new Error('--dsh-home must not be empty')
127
+ }
128
+ return resolve(explicit.trim())
129
+ }
130
+ const configured = env.DSH_HOME
131
+ if (configured !== undefined && configured.trim().length > 0) {
132
+ return resolve(configured.trim())
133
+ }
134
+ return join(home, '.dsh')
135
+ }
136
+
137
+ /**
138
+ * Map a provider package version to its deepseek-harness git tag.
139
+ * @param {string} version - package version, for example `0.2.2-rc.1`.
140
+ * @returns {string} tag, for example `dsh-v0.2.2-rc.1`.
141
+ */
142
+ export function versionToTag(version) {
143
+ if (typeof version !== 'string' || version.trim().length === 0) {
144
+ throw new Error('provider version is empty')
145
+ }
146
+ return `dsh-v${version.trim()}`
147
+ }
148
+
149
+ /**
150
+ * Map a deepseek-harness git tag to the provider package version.
151
+ * @param {string} tag - tag, for example `dsh-v0.2.2-rc.1`.
152
+ * @returns {string} version, for example `0.2.2-rc.1`.
153
+ */
154
+ export function tagToVersion(tag) {
155
+ if (typeof tag !== 'string' || !tag.startsWith('dsh-v') || tag.length === 5) {
156
+ throw new Error(`invalid DSH release tag: ${String(tag)}`)
157
+ }
158
+ return tag.slice(5)
159
+ }
160
+
161
+ /**
162
+ * Whether an installed provider version matches the requested tag.
163
+ * @param {string} version - installed package version.
164
+ * @param {string} tag - requested git tag.
165
+ * @returns {boolean} true when both name the same release.
166
+ */
167
+ export function versionMatchesTag(version, tag) {
168
+ return version === tagToVersion(tag)
169
+ }
170
+
171
+ /**
172
+ * Directory holding one profile's installed provider package.
173
+ * @param {string} profileDir - absolute profile directory.
174
+ * @returns {string} absolute provider package directory.
175
+ */
176
+ export function providerDirFor(profileDir) {
177
+ return join(profileDir, 'node_modules', ...PROVIDER_PACKAGE_NAME.split('/'))
178
+ }
179
+
180
+ /**
181
+ * List profiles that have the target provider installed.
182
+ * @param {string} dshHome - absolute DSH home.
183
+ * @param {{ readdir?: typeof readdir, exists?: (path: string) => boolean }} [io] - injection points for tests.
184
+ * @returns {Promise<string[]>} matching profile names, sorted.
185
+ */
186
+ export async function discoverProfiles(dshHome, io = {}) {
187
+ const readdirImpl = io.readdir ?? readdir
188
+ const existsImpl = io.exists ?? existsSync
189
+ const profilesRoot = join(dshHome, 'profiles')
190
+ let entries
191
+ try {
192
+ entries = await readdirImpl(profilesRoot, { withFileTypes: true })
193
+ } catch {
194
+ return []
195
+ }
196
+ const matches = []
197
+ for (const entry of entries) {
198
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
199
+ if (existsImpl(providerDirFor(join(profilesRoot, entry.name)))) {
200
+ matches.push(entry.name)
201
+ }
202
+ }
203
+ return matches.sort()
204
+ }
205
+
206
+ /**
207
+ * Render a filesystem-safe UTC timestamp used as a backup directory name.
208
+ * @param {Date} [date] - instant to render.
209
+ * @returns {string} timestamp such as `2026-11-06T14-07-00-000Z`.
210
+ */
211
+ export function timestampDir(date = new Date()) {
212
+ return date.toISOString().replace(/[:.]/g, '-')
213
+ }
214
+
215
+ /**
216
+ * Build the GitHub raw URL for one official provider source file.
217
+ * @param {string} tag - release tag.
218
+ * @param {string} file - source file name.
219
+ * @returns {string} raw.githubusercontent.com URL.
220
+ */
221
+ export function rawSourceUrl(tag, file) {
222
+ const path = [
223
+ 'deepseek-ai',
224
+ 'deepseek-harness',
225
+ tag,
226
+ 'packages',
227
+ 'subagent',
228
+ 'subagent-claude-code',
229
+ 'src',
230
+ file,
231
+ ].map(part => encodeURIComponent(part)).join('/')
232
+ return `https://raw.githubusercontent.com/${path}`
233
+ }
234
+
235
+ /**
236
+ * Root directory that owns timestamped backup sets.
237
+ * @param {string} dshHome - absolute DSH home.
238
+ * @returns {string} absolute backup root.
239
+ */
240
+ export function backupsRoot(dshHome) {
241
+ return join(dshHome, 'backups', BACKUP_DIR_NAME)
242
+ }
243
+
244
+ /**
245
+ * Backup directory for one profile inside a timestamped set.
246
+ * @param {string} dshHome - absolute DSH home.
247
+ * @param {string} timestamp - output of {@link timestampDir}.
248
+ * @param {string} profile - profile name.
249
+ * @returns {string} absolute backup directory.
250
+ */
251
+ export function backupProfileDir(dshHome, timestamp, profile) {
252
+ return join(backupsRoot(dshHome), timestamp, assertProfileName(profile))
253
+ }
254
+
255
+ /**
256
+ * Run-log directory reported by the patched provider.
257
+ * @param {string} dshHome - absolute DSH home.
258
+ * @returns {string} absolute log directory.
259
+ */
260
+ export function runLogDirectory(dshHome) {
261
+ return join(dshHome, ...RUN_LOG_RELATIVE_PATH)
262
+ }
263
+
264
+ /**
265
+ * Whether an install record represents an active patch for the given tag.
266
+ * @param {{ tag?: string, restoredAt?: string } | undefined} record - install record.
267
+ * @param {string} tag - requested tag.
268
+ * @returns {boolean} true when the record is unrestored and names the same tag.
269
+ */
270
+ export function isMatchingActiveRecord(record, tag) {
271
+ return record !== undefined
272
+ && record.tag === tag
273
+ && (record.restoredAt === undefined || record.restoredAt === null)
274
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@lifenrir/dsh-external-task-context-patch",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Install a run-log-capable patched @deepseek-ai/dsh-subagent-claude-code into an existing DSH profile for @lifenrir/dsh-external-task-context live logs.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "dsh-external-task-context-patch": "./install.mjs"
9
+ },
10
+ "files": [
11
+ "install.mjs",
12
+ "lib.mjs",
13
+ "src",
14
+ "README.md",
15
+ "README-zh.md"
16
+ ],
17
+ "dependencies": {
18
+ "esbuild": "^0.25.0"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test tests/lib.test.mjs"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/LiFenrir/dsh-external-task-context.git",
29
+ "directory": "patch"
30
+ },
31
+ "homepage": "https://github.com/LiFenrir/dsh-external-task-context/tree/main/patch#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/LiFenrir/dsh-external-task-context/issues"
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Profile-named Claude Code one-shot subagent provider. Every accepted run
3
+ * invokes the official Agent SDK in the delegating Session's workspace and
4
+ * places the SDK-spawned real CLI under the shared subprocess owner.
5
+ *
6
+ * @module @deepseek-ai/dsh-subagent-claude-code
7
+ */
8
+
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import z from '@deepseek-ai/schemastery'
11
+ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
12
+ import {
13
+ assertPositiveFinite,
14
+ NO_START_CAPABILITIES,
15
+ assertUsableCwd,
16
+ type ResolvedSubagentStartRequest,
17
+ type SubagentCapabilities,
18
+ type SubagentProvider,
19
+ } from '@deepseek-ai/dsh-subagent'
20
+ import { DEFAULT_LOG_RETENTION_DAYS } from './log.ts'
21
+ import {
22
+ CLAUDE_CODE_PERMISSION_MODES,
23
+ DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
24
+ DEFAULT_DISPOSE_GRACE_MS,
25
+ claudeCodeStartupFailure,
26
+ startClaudeCodeRun,
27
+ type ClaudeCodePermissionMode,
28
+ type ClaudeCodeRunSpec,
29
+ } from './run.ts'
30
+
31
+ export const name = 'subagent-claude-code'
32
+ export const inject = ['subagents', 'subprocess']
33
+
34
+ const DEFAULT_PROVIDER_NAME = 'claude-code'
35
+
36
+ /* jscpd:ignore-start -- sibling product providers intentionally expose
37
+ * overlapping deployment-owned fields without adding a shared config owner. */
38
+ /** Deployment-owned model, permission, environment, and process-release settings. */
39
+ export interface Config {
40
+ /** Provider name on `ctx.subagents` (default `claude-code`). */
41
+ providerName?: string
42
+ /** Native Claude model fixed for this instance; omitted to inherit Claude settings. */
43
+ model?: string
44
+ /**
45
+ * Explicit environment entries layered over the subprocess seam's
46
+ * credential-scrubbed parent environment.
47
+ */
48
+ env?: Record<string, string>
49
+ /**
50
+ * Native non-interactive mode fixed for this Provider instance. Defaults to
51
+ * `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier,
52
+ * `plan` returns a plan without approving execution, and
53
+ * `bypassPermissions` explicitly skips permission checks.
54
+ */
55
+ permissionMode?: ClaudeCodePermissionMode
56
+ /** Grace in milliseconds between Claude Code managed-range termination tiers. */
57
+ disposeGraceMs?: number
58
+ /**
59
+ * Directory for per-run JSONL and metadata logs. Defaults, resolved at run
60
+ * time, to `<DSH_HOME>/logs/subagent-claude-code`.
61
+ */
62
+ logDirectory?: string
63
+ /** Days to retain finished run logs (default 7; must be a positive finite number). */
64
+ logRetentionDays?: number
65
+ /**
66
+ * Whether Claude Code persists its native session transcript, which the SDK
67
+ * writes under `<CLAUDE_CONFIG_DIR>/projects/<cwd>/<sessionId>.jsonl`
68
+ * (default true).
69
+ */
70
+ persistNativeTranscript?: boolean
71
+ /** Whether the SDK streams partial assistant messages so deltas reach the log (default true). */
72
+ includePartialMessages?: boolean
73
+ }
74
+
75
+ export const Config: z<Config> = z.object({
76
+ providerName: z.string().min(1).default(DEFAULT_PROVIDER_NAME),
77
+ model: z.string().min(1),
78
+ env: z.dict(z.string()).default({}),
79
+ permissionMode: z.union([...CLAUDE_CODE_PERMISSION_MODES])
80
+ .default(DEFAULT_CLAUDE_CODE_PERMISSION_MODE),
81
+ disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
82
+ logDirectory: z.string().min(1),
83
+ logRetentionDays: z.number().default(DEFAULT_LOG_RETENTION_DAYS),
84
+ persistNativeTranscript: z.boolean().default(true),
85
+ includePartialMessages: z.boolean().default(true),
86
+ })
87
+
88
+ type ResolvedConfig = Omit<Required<Config>, 'model' | 'logDirectory'>
89
+ & Pick<Config, 'model' | 'logDirectory'>
90
+ /* jscpd:ignore-end */
91
+
92
+ /* jscpd:ignore-start -- Cordis registration and shared-seam plumbing mirror
93
+ * the Codex sibling; each product's lifecycle remains package-private. */
94
+ class ClaudeCodeProvider implements SubagentProvider {
95
+ readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
96
+ readonly inheritsParentContext = false
97
+
98
+ constructor(
99
+ readonly name: string,
100
+ private readonly ctx: Context,
101
+ private readonly config: ResolvedConfig,
102
+ ) {}
103
+
104
+ async start(request: ResolvedSubagentStartRequest) {
105
+ let cwd: string
106
+ try {
107
+ cwd = assertUsableCwd('subagent-claude-code', 'child cwd', request.cwd)
108
+ } catch (error: unknown) {
109
+ if (request.signal.aborted) {
110
+ throw new Error(
111
+ 'subagent-claude-code: request was aborted before SDK startup',
112
+ )
113
+ }
114
+ const failure = claudeCodeStartupFailure(error)
115
+ this.ctx.logger.warn(
116
+ `subagent-claude-code "${this.name}": child start failed: %o`,
117
+ failure,
118
+ )
119
+ throw failure
120
+ }
121
+ const spec: ClaudeCodeRunSpec = {
122
+ cwd,
123
+ ...this.config.model === undefined ? {} : { model: this.config.model },
124
+ permissionMode: this.config.permissionMode,
125
+ env: this.config.env,
126
+ disposeGraceMs: this.config.disposeGraceMs,
127
+ ...this.config.logDirectory === undefined
128
+ ? {}
129
+ : { logDirectory: this.config.logDirectory },
130
+ logRetentionDays: this.config.logRetentionDays,
131
+ persistNativeTranscript: this.config.persistNativeTranscript,
132
+ includePartialMessages: this.config.includePartialMessages,
133
+ spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
134
+ onError: (error, stopReason) => {
135
+ this.ctx.logger.warn(
136
+ `subagent-claude-code "${this.name}": child run failed (${stopReason}): %o`,
137
+ error,
138
+ )
139
+ },
140
+ onLogError: (error) => {
141
+ this.ctx.logger.warn(
142
+ `subagent-claude-code "${this.name}": run log write failed: %o`,
143
+ error,
144
+ )
145
+ },
146
+ }
147
+ return startClaudeCodeRun(request, spec)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Register one Profile-named Claude Code provider.
153
+ * @param ctx - context carrying shared subagent and subprocess services.
154
+ * @param config - registry name, optional model, permission mode, child environment, disposal grace, and run-log policy.
155
+ */
156
+ export function apply(ctx: Context, config: Config): void {
157
+ const resolved: ResolvedConfig = {
158
+ providerName: config.providerName ?? DEFAULT_PROVIDER_NAME,
159
+ ...config.model === undefined ? {} : { model: config.model },
160
+ env: config.env as Record<string, string>,
161
+ permissionMode: config.permissionMode ?? DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
162
+ disposeGraceMs: config.disposeGraceMs as number,
163
+ logRetentionDays: config.logRetentionDays ?? DEFAULT_LOG_RETENTION_DAYS,
164
+ persistNativeTranscript: config.persistNativeTranscript ?? true,
165
+ includePartialMessages: config.includePartialMessages ?? true,
166
+ ...config.logDirectory === undefined ? {} : { logDirectory: config.logDirectory },
167
+ }
168
+ assertPositiveFinite(
169
+ 'subagent-claude-code',
170
+ 'disposeGraceMs',
171
+ resolved.disposeGraceMs,
172
+ )
173
+ if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
174
+ throw new Error(
175
+ `subagent-claude-code: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
176
+ )
177
+ }
178
+ assertPositiveFinite(
179
+ 'subagent-claude-code',
180
+ 'logRetentionDays',
181
+ resolved.logRetentionDays,
182
+ )
183
+ ctx.subagents.registerProvider(new ClaudeCodeProvider(
184
+ resolved.providerName,
185
+ ctx,
186
+ resolved,
187
+ ))
188
+ }
189
+ /* jscpd:ignore-end */