@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/src/log.ts ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Per-run Claude Code execution logs: an append-only JSONL message stream and
3
+ * a rewritten metadata sidecar. Logging is best effort: every failure is
4
+ * reported to the supplied sink and never reaches the run.
5
+ *
6
+ * @module @deepseek-ai/dsh-subagent-claude-code/log
7
+ */
8
+
9
+ import {
10
+ createWriteStream,
11
+ mkdirSync,
12
+ readdirSync,
13
+ rmSync,
14
+ statSync,
15
+ writeFileSync,
16
+ type WriteStream,
17
+ } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { join, resolve } from 'node:path'
20
+ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
21
+
22
+ /** Default number of days a finished run log is retained. */
23
+ export const DEFAULT_LOG_RETENTION_DAYS = 7
24
+
25
+ /** Milliseconds in one day, used by retention cleanup. */
26
+ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1_000
27
+
28
+ /** Terminal status recorded for a finished run. */
29
+ export type RunLogStatus = 'completed' | 'failed' | 'cancelled'
30
+
31
+ /** Metadata sidecar status, which is `running` until the run terminates. */
32
+ type MetaStatus = 'running' | RunLogStatus
33
+
34
+ /** Facts needed to open one run's logs. */
35
+ export interface RunLogWriterOptions {
36
+ /** Directory that receives the JSONL and metadata files. */
37
+ readonly directory: string
38
+ /** Run identity, also the file stem used by both files. */
39
+ readonly runId: string
40
+ /** Child working directory recorded in the start line and metadata. */
41
+ readonly cwd: string
42
+ /** Selected native model, or null when native settings choose it. */
43
+ readonly model?: string | null
44
+ /** Run start time; defaults to the writer creation time. */
45
+ readonly startedAt?: number
46
+ /** Sink for swallowed logging failures. */
47
+ readonly onError?: (error: Error) => void
48
+ }
49
+
50
+ /** Append-only writer for one run's execution log. */
51
+ export interface RunLogWriter {
52
+ /** Append one raw SDK message. */
53
+ message(message: SDKMessage): void
54
+ /** Append the terminal record and rewrite the metadata sidecar. */
55
+ finish(status: RunLogStatus, result?: string | null): void
56
+ /** Stop accepting records and release the JSONL stream. */
57
+ close(): void
58
+ }
59
+
60
+ /** Shape of the rewritten metadata sidecar. */
61
+ interface MetaFile {
62
+ readonly runId: string
63
+ readonly cwd: string
64
+ readonly model: string | null
65
+ readonly startedAt: number
66
+ readonly endedAt?: number
67
+ readonly status: MetaStatus
68
+ readonly claudeSessionId?: string
69
+ readonly claudeConfigDir: string
70
+ }
71
+
72
+ /**
73
+ * Resolve the DeepSeek Harness home directory.
74
+ *
75
+ * Inlined from `@deepseek-ai/dsh-home-paths` so the patched bundle has no
76
+ * dependency on that package, which the official provider does not ship.
77
+ * @returns `$DSH_HOME` when set and non-empty, otherwise `<os home>/.dsh`.
78
+ */
79
+ function resolveDshHome(): string {
80
+ const configured = process.env.DSH_HOME
81
+ return configured !== undefined && configured.trim().length > 0
82
+ ? resolve(configured)
83
+ : join(homedir(), '.dsh')
84
+ }
85
+
86
+ /**
87
+ * Default log directory under the resolved DeepSeek Harness home.
88
+ * @returns `<DSH_HOME>/logs/subagent-claude-code`, resolved when called.
89
+ */
90
+ export function defaultRunLogDirectory(): string {
91
+ return join(resolveDshHome(), 'logs', 'subagent-claude-code')
92
+ }
93
+
94
+ /**
95
+ * Resolve Claude Code's native configuration root.
96
+ * @returns `$CLAUDE_CONFIG_DIR` when set and non-empty, otherwise `<os home>/.claude`.
97
+ */
98
+ function resolveClaudeConfigDir(): string {
99
+ const configured = process.env.CLAUDE_CONFIG_DIR
100
+ return configured !== undefined && configured.trim().length > 0
101
+ ? resolve(configured)
102
+ : join(homedir(), '.claude')
103
+ }
104
+
105
+ /** Whether a directory entry carries one of the two run-log file names. */
106
+ function isRunLogFile(name: string): boolean {
107
+ return name.endsWith('.jsonl') || name.endsWith('.meta.json')
108
+ }
109
+
110
+ /**
111
+ * Delete run-log files older than the retention window.
112
+ * @param directory - log directory to scan; absence and unreadable entries are ignored.
113
+ * @param retentionDays - maximum file age in days.
114
+ */
115
+ export function cleanupExpiredRunLogs(directory: string, retentionDays: number): void {
116
+ const cutoff = Date.now() - retentionDays * MILLISECONDS_PER_DAY
117
+ let names: string[]
118
+ try {
119
+ names = readdirSync(directory)
120
+ } catch {
121
+ // A missing or unreadable directory leaves nothing to clean.
122
+ return
123
+ }
124
+ for (const name of names) {
125
+ if (!isRunLogFile(name)) continue
126
+ const path = join(directory, name)
127
+ try {
128
+ if (statSync(path).mtimeMs < cutoff) rmSync(path, { force: true })
129
+ } catch {
130
+ // A concurrent run may have removed or replaced the entry.
131
+ }
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Create the best-effort writer for one run.
137
+ * @param options - directory, run identity, and metadata facts.
138
+ * @returns a writer that swallows every logging failure through `options.onError`.
139
+ */
140
+ export function createRunLogWriter(options: RunLogWriterOptions): RunLogWriter {
141
+ const report = (error: unknown): void => {
142
+ try {
143
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
144
+ } catch {
145
+ // The diagnostic sink cannot break the log writer.
146
+ }
147
+ }
148
+
149
+ const jsonlPath = join(options.directory, `${options.runId}.jsonl`)
150
+ const metaPath = join(options.directory, `${options.runId}.meta.json`)
151
+ const startedAt = options.startedAt ?? Date.now()
152
+ let sessionId: string | undefined
153
+ let status: MetaStatus = 'running'
154
+ let endedAt: number | undefined
155
+ let finished = false
156
+ let closed = false
157
+
158
+ const writeMeta = (): void => {
159
+ const meta: MetaFile = {
160
+ runId: options.runId,
161
+ cwd: options.cwd,
162
+ model: options.model ?? null,
163
+ startedAt,
164
+ status,
165
+ claudeConfigDir: resolveClaudeConfigDir(),
166
+ ...endedAt === undefined ? {} : { endedAt },
167
+ ...sessionId === undefined ? {} : { claudeSessionId: sessionId },
168
+ }
169
+ try {
170
+ writeFileSync(metaPath, `${JSON.stringify(meta, null, 2)}\n`, {
171
+ encoding: 'utf8',
172
+ mode: 0o600,
173
+ })
174
+ } catch (error) {
175
+ report(error)
176
+ }
177
+ }
178
+
179
+ let stream: WriteStream
180
+ try {
181
+ mkdirSync(options.directory, { recursive: true, mode: 0o700 })
182
+ stream = createWriteStream(jsonlPath, { flags: 'a', mode: 0o600 })
183
+ stream.on('error', report)
184
+ } catch (error) {
185
+ report(error)
186
+ return { message() {}, finish() {}, close() {} }
187
+ }
188
+
189
+ const writeLine = (record: object): void => {
190
+ if (closed) return
191
+ try {
192
+ stream.write(`${JSON.stringify(record)}\n`)
193
+ } catch (error) {
194
+ report(error)
195
+ }
196
+ }
197
+
198
+ writeLine({
199
+ kind: 'start',
200
+ time: startedAt,
201
+ runId: options.runId,
202
+ cwd: options.cwd,
203
+ model: options.model ?? null,
204
+ })
205
+ writeMeta()
206
+
207
+ return {
208
+ message(message: SDKMessage): void {
209
+ if (finished || closed) return
210
+ const incoming = message.session_id
211
+ if (sessionId === undefined && typeof incoming === 'string' && incoming.length > 0) {
212
+ sessionId = incoming
213
+ writeMeta()
214
+ }
215
+ writeLine({ kind: 'message', time: Date.now(), message })
216
+ },
217
+ finish(next: RunLogStatus, result: string | null = null): void {
218
+ if (finished || closed) return
219
+ finished = true
220
+ status = next
221
+ endedAt = Date.now()
222
+ writeLine({ kind: 'end', time: endedAt, status: next, result })
223
+ writeMeta()
224
+ },
225
+ close(): void {
226
+ if (closed) return
227
+ closed = true
228
+ try {
229
+ stream.end()
230
+ } catch (error) {
231
+ report(error)
232
+ }
233
+ },
234
+ }
235
+ }