theslopmachine 1.0.7 → 1.0.8
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.
|
@@ -113,14 +113,12 @@ Packages the Claude project directory associated with a task root.
|
|
|
113
113
|
|
|
114
114
|
Required:
|
|
115
115
|
- `--task-root <task-root>`
|
|
116
|
-
- `--output <zip-path>`
|
|
117
116
|
|
|
118
117
|
Important options:
|
|
119
|
-
- `--
|
|
120
|
-
- `--metadata-file <path>` overrides the default `<task-root>/metadata.json`
|
|
118
|
+
- `--output <zip-path>` writes the zip to a custom path; default is `<task-root>/claude-sessions.zip`
|
|
121
119
|
- `--label <text>` adds reporting context
|
|
122
120
|
|
|
123
|
-
The helper
|
|
121
|
+
The helper copies the Claude project/session directory as-is, removes `.DS_Store` files and top-level `.jsonl` transcripts under 25KB from the staged copy, zips the folder contents directly, and writes a single zip. It does not normalize or rewrite transcript files.
|
|
124
122
|
|
|
125
123
|
### `analyze_claude_project_dir.mjs`
|
|
126
124
|
|
|
@@ -184,10 +182,6 @@ Common usage:
|
|
|
184
182
|
- `python3 convert_ai_session.py -i session.jsonl -o converted.json`
|
|
185
183
|
- `python3 convert_ai_session.py -i session.jsonl --format claude`
|
|
186
184
|
|
|
187
|
-
### `normalize_claude_session.py`
|
|
188
|
-
|
|
189
|
-
Normalizes Claude JSONL transcript structure for packaging and review.
|
|
190
|
-
|
|
191
185
|
### `strip_session_parent.py`
|
|
192
186
|
|
|
193
187
|
Removes parent/session ancestry fields from exported session artifacts when needed for sanitized review.
|
|
@@ -8,25 +8,21 @@ import { spawn } from 'node:child_process'
|
|
|
8
8
|
import { parseArgs, emitFailure, emitSuccess, printUsageAndExit } from './claude_worker_common.mjs'
|
|
9
9
|
|
|
10
10
|
const argv = parseArgs(process.argv.slice(2))
|
|
11
|
+
const TINY_ROOT_TRANSCRIPT_MAX_BYTES = 25 * 1024
|
|
11
12
|
|
|
12
13
|
if (argv.help === '1') {
|
|
13
14
|
printUsageAndExit(`Usage:
|
|
14
|
-
node ~/slopmachine/utils/package_claude_session.mjs --task-root <task-root>
|
|
15
|
+
node ~/slopmachine/utils/package_claude_session.mjs --task-root <task-root> [options]
|
|
15
16
|
|
|
16
17
|
Required:
|
|
17
18
|
--task-root <task-root>
|
|
18
|
-
--output <zip-path>
|
|
19
19
|
|
|
20
20
|
Options:
|
|
21
|
+
--output <zip-path> Zip output path (default: <task-root>/claude-sessions.zip)
|
|
21
22
|
--label <text> Optional package label for reporting
|
|
22
|
-
--metadata-file <path> Metadata JSON containing the original prompt (default: ./metadata.json from task root)
|
|
23
23
|
`)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const SMALL_PRE_ANCHOR_TRANSCRIPT_MAX_BYTES = 50 * 1024
|
|
27
|
-
const TINY_TRANSCRIPT_PRUNE_MAX_BYTES = 25 * 1024
|
|
28
|
-
const EARLY_USER_MESSAGE_LIMIT = 8
|
|
29
|
-
|
|
30
26
|
async function pathExists(targetPath) {
|
|
31
27
|
try {
|
|
32
28
|
await fs.access(targetPath)
|
|
@@ -82,215 +78,48 @@ async function createZipArchive(sourceDir, outputPath) {
|
|
|
82
78
|
throw lastError || new Error('No usable PowerShell shell was found for zip creation')
|
|
83
79
|
}
|
|
84
80
|
|
|
85
|
-
|
|
86
|
-
const result = await run('zip', ['-9', '-q', '-r', outputPath, '.'], sourceDir)
|
|
87
|
-
if (result.code !== 0) {
|
|
88
|
-
throw new Error((result.stderr || result.stdout).trim() || `zip failed with exit ${result.code}`)
|
|
89
|
-
}
|
|
90
|
-
} catch (error) {
|
|
91
|
-
throw new Error(error instanceof Error ? error.message : 'zip command is required to package Claude sessions')
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function normalizeClaudeJsonlFile(inputPath) {
|
|
96
|
-
const normalizerScript = path.join(path.dirname(new URL(import.meta.url).pathname), 'normalize_claude_session.py')
|
|
97
|
-
const outputPath = `${inputPath}.normalized`
|
|
98
|
-
await fs.rm(outputPath, { force: true }).catch(() => {})
|
|
99
|
-
const result = await run('python3', [normalizerScript, inputPath, '--output', outputPath], path.dirname(inputPath))
|
|
81
|
+
const result = await run('zip', ['-9', '-q', '-r', outputPath, '.'], sourceDir)
|
|
100
82
|
if (result.code !== 0) {
|
|
101
|
-
throw new Error(
|
|
102
|
-
}
|
|
103
|
-
await fs.rm(inputPath, { force: true })
|
|
104
|
-
await fs.rename(outputPath, inputPath)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function normalizeForPromptMatch(value) {
|
|
108
|
-
return String(value || '')
|
|
109
|
-
.replace(/\r\n/g, '\n')
|
|
110
|
-
.replace(/[ \t]+/g, ' ')
|
|
111
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
112
|
-
.trim()
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function buildPromptNeedles(prompt) {
|
|
116
|
-
const normalized = normalizeForPromptMatch(prompt)
|
|
117
|
-
if (!normalized) return []
|
|
118
|
-
const needles = [normalized]
|
|
119
|
-
const paragraphs = normalized
|
|
120
|
-
.split(/\n\s*\n/)
|
|
121
|
-
.map((part) => part.trim())
|
|
122
|
-
.filter((part) => part.length >= 120)
|
|
123
|
-
|
|
124
|
-
for (const paragraph of paragraphs.slice(0, 5)) {
|
|
125
|
-
needles.push(paragraph)
|
|
83
|
+
throw new Error((result.stderr || result.stdout).trim() || `zip failed with exit ${result.code}`)
|
|
126
84
|
}
|
|
127
|
-
|
|
128
|
-
if (normalized.length > 2000) {
|
|
129
|
-
needles.push(normalized.slice(0, 2000).trim())
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return [...new Set(needles.filter((needle) => needle.length >= 80))]
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function extractTextFromClaudeContent(content) {
|
|
136
|
-
if (typeof content === 'string') return content
|
|
137
|
-
if (Array.isArray(content)) {
|
|
138
|
-
return content.map((item) => {
|
|
139
|
-
if (typeof item === 'string') return item
|
|
140
|
-
if (item && typeof item === 'object') {
|
|
141
|
-
if (typeof item.text === 'string') return item.text
|
|
142
|
-
if (typeof item.content === 'string') return item.content
|
|
143
|
-
}
|
|
144
|
-
return ''
|
|
145
|
-
}).join('\n')
|
|
146
|
-
}
|
|
147
|
-
if (content && typeof content === 'object') {
|
|
148
|
-
if (typeof content.text === 'string') return content.text
|
|
149
|
-
if (typeof content.content === 'string') return content.content
|
|
150
|
-
}
|
|
151
|
-
return ''
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function extractClaudeUserText(record) {
|
|
155
|
-
if (!record || typeof record !== 'object') return ''
|
|
156
|
-
const role = record.message?.role || record.role || record.type
|
|
157
|
-
if (role !== 'user' && record.type !== 'user') return ''
|
|
158
|
-
return extractTextFromClaudeContent(record.message?.content ?? record.content)
|
|
159
85
|
}
|
|
160
86
|
|
|
161
|
-
function
|
|
162
|
-
const
|
|
163
|
-
record?.timestamp,
|
|
164
|
-
record?.created_at,
|
|
165
|
-
record?.message?.timestamp,
|
|
166
|
-
record?.message?.created_at,
|
|
167
|
-
]
|
|
168
|
-
for (const candidate of candidates) {
|
|
169
|
-
if (typeof candidate !== 'string' && typeof candidate !== 'number') continue
|
|
170
|
-
const value = typeof candidate === 'number' ? candidate : Date.parse(candidate)
|
|
171
|
-
if (Number.isFinite(value)) return value
|
|
172
|
-
}
|
|
173
|
-
return null
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function readOriginalPrompt({ cwd, metadataFile }) {
|
|
177
|
-
const resolvedMetadataFile = metadataFile
|
|
178
|
-
? path.resolve(metadataFile)
|
|
179
|
-
: path.resolve(cwd, 'metadata.json')
|
|
180
|
-
if (!await pathExists(resolvedMetadataFile)) {
|
|
181
|
-
return { prompt: '', metadataFile: resolvedMetadataFile, missing: true }
|
|
182
|
-
}
|
|
183
|
-
const metadata = JSON.parse(await fs.readFile(resolvedMetadataFile, 'utf8'))
|
|
184
|
-
const prompt = typeof metadata?.prompt === 'string' ? metadata.prompt : ''
|
|
185
|
-
return { prompt, metadataFile: resolvedMetadataFile, missing: false }
|
|
186
|
-
}
|
|
87
|
+
async function removeDsStoreFiles(rootDir) {
|
|
88
|
+
const entries = await fs.readdir(rootDir, { withFileTypes: true })
|
|
187
89
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
let firstTimestamp = null
|
|
193
|
-
let userMessagesSeen = 0
|
|
194
|
-
let promptMatched = false
|
|
195
|
-
|
|
196
|
-
for (const line of lines) {
|
|
197
|
-
if (!line.trim()) continue
|
|
198
|
-
let record = null
|
|
199
|
-
try {
|
|
200
|
-
record = JSON.parse(line)
|
|
201
|
-
} catch {
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const absolutePath = path.join(rootDir, entry.name)
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
await removeDsStoreFiles(absolutePath)
|
|
202
94
|
continue
|
|
203
95
|
}
|
|
204
|
-
if (firstTimestamp == null) {
|
|
205
|
-
firstTimestamp = extractClaudeTimestamp(record)
|
|
206
|
-
}
|
|
207
|
-
const userText = extractClaudeUserText(record)
|
|
208
|
-
if (!userText) continue
|
|
209
|
-
userMessagesSeen += 1
|
|
210
|
-
const normalizedUserText = normalizeForPromptMatch(userText)
|
|
211
|
-
if (promptNeedles.some((needle) => normalizedUserText.includes(needle))) {
|
|
212
|
-
promptMatched = true
|
|
213
|
-
break
|
|
214
|
-
}
|
|
215
|
-
if (userMessagesSeen >= EARLY_USER_MESSAGE_LIMIT) break
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return {
|
|
219
|
-
path: transcriptPath,
|
|
220
|
-
size: stat.size,
|
|
221
|
-
mtimeMs: stat.mtimeMs,
|
|
222
|
-
firstTimestamp: firstTimestamp ?? stat.mtimeMs,
|
|
223
|
-
promptMatched,
|
|
224
|
-
userMessagesSeen,
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
96
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const promptNeedles = buildPromptNeedles(prompt)
|
|
231
|
-
if (missing || promptNeedles.length === 0) {
|
|
232
|
-
return {
|
|
233
|
-
anchor: null,
|
|
234
|
-
removed: [],
|
|
235
|
-
inspected: [],
|
|
236
|
-
filter_applied: false,
|
|
237
|
-
filter_reason: missing ? `metadata prompt file missing: ${resolvedMetadataFile}` : 'metadata prompt is empty or too short for safe matching',
|
|
238
|
-
metadata_file: resolvedMetadataFile,
|
|
97
|
+
if (entry.isFile() && entry.name === '.DS_Store') {
|
|
98
|
+
await fs.rm(absolutePath, { force: true })
|
|
239
99
|
}
|
|
240
100
|
}
|
|
101
|
+
}
|
|
241
102
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
103
|
+
async function removeTinyRootTranscripts(rootDir) {
|
|
104
|
+
const entries = await fs.readdir(rootDir, { withFileTypes: true })
|
|
105
|
+
const removed = []
|
|
246
106
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return {
|
|
251
|
-
anchor: null,
|
|
252
|
-
removed: [],
|
|
253
|
-
inspected,
|
|
254
|
-
filter_applied: false,
|
|
255
|
-
filter_reason: 'no top-level Claude transcript matched the original prompt in early user messages',
|
|
256
|
-
metadata_file: resolvedMetadataFile,
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
|
|
109
|
+
continue
|
|
257
110
|
}
|
|
258
|
-
}
|
|
259
111
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (!tinyNoise && !smallPreAnchor) continue
|
|
266
|
-
await fs.rm(entry.path, { force: true })
|
|
267
|
-
removed.push({
|
|
268
|
-
...entry,
|
|
269
|
-
removalReason: tinyNoise
|
|
270
|
-
? `root-level transcript below ${TINY_TRANSCRIPT_PRUNE_MAX_BYTES} bytes`
|
|
271
|
-
: `pre-anchor transcript below ${SMALL_PRE_ANCHOR_TRANSCRIPT_MAX_BYTES} bytes`,
|
|
272
|
-
})
|
|
273
|
-
}
|
|
112
|
+
const absolutePath = path.join(rootDir, entry.name)
|
|
113
|
+
const stat = await fs.stat(absolutePath)
|
|
114
|
+
if (stat.size >= TINY_ROOT_TRANSCRIPT_MAX_BYTES) {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
274
117
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
removed,
|
|
278
|
-
inspected,
|
|
279
|
-
filter_applied: true,
|
|
280
|
-
filter_reason: 'removed root-level transcripts under 25KB except the prompt anchor, and pre-anchor transcripts under 50KB',
|
|
281
|
-
metadata_file: resolvedMetadataFile,
|
|
118
|
+
await fs.rm(absolutePath, { force: true })
|
|
119
|
+
removed.push({ file: entry.name, size: stat.size })
|
|
282
120
|
}
|
|
283
|
-
}
|
|
284
121
|
|
|
285
|
-
|
|
286
|
-
const inspectedByPath = new Map(inspected.map((entry) => [entry.path, entry]))
|
|
287
|
-
for (const transcriptTarget of transcriptTargets) {
|
|
288
|
-
if (!await pathExists(transcriptTarget)) continue
|
|
289
|
-
const entry = inspectedByPath.get(transcriptTarget)
|
|
290
|
-
if (!entry?.firstTimestamp || !Number.isFinite(entry.firstTimestamp)) continue
|
|
291
|
-
const timestamp = new Date(entry.firstTimestamp)
|
|
292
|
-
await fs.utimes(transcriptTarget, timestamp, timestamp).catch(() => {})
|
|
293
|
-
}
|
|
122
|
+
return removed
|
|
294
123
|
}
|
|
295
124
|
|
|
296
125
|
async function listFilesRecursive(rootDir, relativePrefix = '') {
|
|
@@ -314,22 +143,6 @@ async function listFilesRecursive(rootDir, relativePrefix = '') {
|
|
|
314
143
|
return files
|
|
315
144
|
}
|
|
316
145
|
|
|
317
|
-
async function removeDsStoreFiles(rootDir) {
|
|
318
|
-
const entries = await fs.readdir(rootDir, { withFileTypes: true })
|
|
319
|
-
|
|
320
|
-
for (const entry of entries) {
|
|
321
|
-
const absolutePath = path.join(rootDir, entry.name)
|
|
322
|
-
if (entry.isDirectory()) {
|
|
323
|
-
await removeDsStoreFiles(absolutePath)
|
|
324
|
-
continue
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (entry.isFile() && entry.name === '.DS_Store') {
|
|
328
|
-
await fs.rm(absolutePath, { force: true })
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
146
|
async function resolveClaudeProjectDir(taskRoot) {
|
|
334
147
|
const projectsRoot = path.join(os.homedir(), '.claude', 'projects')
|
|
335
148
|
const resolvedTaskRoot = await fs.realpath(taskRoot).catch(() => path.resolve(taskRoot))
|
|
@@ -348,72 +161,31 @@ async function resolveClaudeProjectDir(taskRoot) {
|
|
|
348
161
|
throw new Error(`Claude project directory not found for task root: ${resolvedTaskRoot}`)
|
|
349
162
|
}
|
|
350
163
|
|
|
351
|
-
async function stageClaudeProjectDir(sourceProjectDir, tempRoot) {
|
|
352
|
-
const packageRoot = path.join(tempRoot, path.basename(sourceProjectDir))
|
|
353
|
-
await fs.mkdir(tempRoot, { recursive: true })
|
|
354
|
-
await fs.cp(sourceProjectDir, packageRoot, { recursive: true })
|
|
355
|
-
|
|
356
|
-
const allFiles = await listFilesRecursive(packageRoot)
|
|
357
|
-
const transcriptTargets = allFiles
|
|
358
|
-
.filter((relativePath) => relativePath.endsWith('.jsonl') && !relativePath.includes(path.sep))
|
|
359
|
-
.map((relativePath) => path.join(packageRoot, relativePath))
|
|
360
|
-
|
|
361
|
-
return {
|
|
362
|
-
packageRoot,
|
|
363
|
-
transcriptTargets,
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
164
|
try {
|
|
368
165
|
const taskRoot = argv['task-root'] ? path.resolve(argv['task-root']) : null
|
|
369
166
|
if (!taskRoot) {
|
|
370
167
|
throw new Error('Missing --task-root')
|
|
371
168
|
}
|
|
169
|
+
|
|
372
170
|
const sourceProjectDir = await resolveClaudeProjectDir(taskRoot)
|
|
171
|
+
const outputPath = argv.output ? path.resolve(argv.output) : path.join(taskRoot, 'claude-sessions.zip')
|
|
373
172
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'slopmachine-claude-project-'))
|
|
374
|
-
const
|
|
375
|
-
await removeDsStoreFiles(packageRoot)
|
|
376
|
-
const filterResult = await filterPrePromptTinyTranscripts(transcriptTargets, {
|
|
377
|
-
cwd: taskRoot,
|
|
378
|
-
metadataFile: argv['metadata-file'] || null,
|
|
379
|
-
})
|
|
380
|
-
for (const transcriptTargetPath of transcriptTargets) {
|
|
381
|
-
if (!await pathExists(transcriptTargetPath)) continue
|
|
382
|
-
await normalizeClaudeJsonlFile(transcriptTargetPath)
|
|
383
|
-
}
|
|
384
|
-
await preserveTranscriptTimes(transcriptTargets, filterResult.inspected)
|
|
385
|
-
const included = (await listFilesRecursive(packageRoot)).sort((left, right) => left.localeCompare(right))
|
|
173
|
+
const packageRoot = path.join(tempRoot, path.basename(sourceProjectDir))
|
|
386
174
|
|
|
387
175
|
try {
|
|
388
|
-
await
|
|
176
|
+
await fs.cp(sourceProjectDir, packageRoot, { recursive: true })
|
|
177
|
+
await removeDsStoreFiles(packageRoot)
|
|
178
|
+
const tinyRootTranscriptsRemoved = await removeTinyRootTranscripts(packageRoot)
|
|
179
|
+
const included = (await listFilesRecursive(packageRoot)).sort((left, right) => left.localeCompare(right))
|
|
180
|
+
await createZipArchive(packageRoot, outputPath)
|
|
181
|
+
|
|
389
182
|
emitSuccess(path.basename(sourceProjectDir), {
|
|
390
|
-
output:
|
|
183
|
+
output: outputPath,
|
|
391
184
|
project_dir: sourceProjectDir,
|
|
392
185
|
label: argv.label || null,
|
|
393
186
|
included,
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
prompt_anchor: filterResult.anchor
|
|
397
|
-
? path.basename(filterResult.anchor.path)
|
|
398
|
-
: null,
|
|
399
|
-
pre_anchor_removed: filterResult.removed.map((entry) => ({
|
|
400
|
-
file: path.basename(entry.path),
|
|
401
|
-
size: entry.size,
|
|
402
|
-
first_timestamp: new Date(entry.firstTimestamp).toISOString(),
|
|
403
|
-
reason: entry.removalReason || null,
|
|
404
|
-
})),
|
|
405
|
-
prompt_anchor_filter_applied: filterResult.filter_applied,
|
|
406
|
-
prompt_anchor_filter_reason: filterResult.filter_reason,
|
|
407
|
-
prompt_anchor_metadata_file: filterResult.metadata_file,
|
|
408
|
-
transcript_chronology: filterResult.inspected.map((entry) => ({
|
|
409
|
-
file: path.basename(entry.path),
|
|
410
|
-
size: entry.size,
|
|
411
|
-
first_timestamp: new Date(entry.firstTimestamp).toISOString(),
|
|
412
|
-
prompt_matched: entry.promptMatched,
|
|
413
|
-
user_messages_scanned: entry.userMessagesSeen,
|
|
414
|
-
included: !filterResult.removed.some((removed) => removed.path === entry.path),
|
|
415
|
-
tiny_under_25kb: entry.size < TINY_TRANSCRIPT_PRUNE_MAX_BYTES,
|
|
416
|
-
})),
|
|
187
|
+
tiny_root_transcripts_removed: tinyRootTranscriptsRemoved,
|
|
188
|
+
packaging_mode: 'raw_claude_project_dir',
|
|
417
189
|
})
|
|
418
190
|
} finally {
|
|
419
191
|
await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
package/package.json
CHANGED
package/src/send-data.js
CHANGED
|
@@ -430,14 +430,10 @@ async function exportClaudeProjectArtifacts(claudeSessions, taskRoot, stagingDir
|
|
|
430
430
|
const utilsDir = path.join(buildPaths().slopmachineDir, 'utils')
|
|
431
431
|
const packageClaudeSessionScript = path.join(utilsDir, 'package_claude_session.mjs')
|
|
432
432
|
const outputPath = path.join(stagingDir, 'claude-sessions.zip')
|
|
433
|
-
const trackedSessionIds = [...new Set(claudeSessions.map((session) => session.sessionId).filter(Boolean))]
|
|
434
|
-
|
|
435
433
|
const packageResult = await runCommand(process.execPath, [
|
|
436
434
|
packageClaudeSessionScript,
|
|
437
435
|
'--task-root',
|
|
438
436
|
taskRoot,
|
|
439
|
-
'--session-ids',
|
|
440
|
-
trackedSessionIds.join(','),
|
|
441
437
|
'--label',
|
|
442
438
|
'claude-sessions',
|
|
443
439
|
'--output',
|