theslopmachine 0.6.2 → 0.7.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 (76) hide show
  1. package/MANUAL.md +21 -6
  2. package/README.md +55 -7
  3. package/RELEASE.md +15 -0
  4. package/assets/agents/developer.md +41 -1
  5. package/assets/agents/slopmachine-claude.md +100 -60
  6. package/assets/agents/slopmachine.md +40 -17
  7. package/assets/claude/agents/developer.md +42 -5
  8. package/assets/skills/clarification-gate/SKILL.md +25 -5
  9. package/assets/skills/claude-worker-management/SKILL.md +280 -57
  10. package/assets/skills/developer-session-lifecycle/SKILL.md +81 -37
  11. package/assets/skills/development-guidance/SKILL.md +21 -1
  12. package/assets/skills/evaluation-triage/SKILL.md +32 -23
  13. package/assets/skills/final-evaluation-orchestration/SKILL.md +86 -50
  14. package/assets/skills/hardening-gate/SKILL.md +17 -3
  15. package/assets/skills/integrated-verification/SKILL.md +3 -3
  16. package/assets/skills/planning-gate/SKILL.md +32 -3
  17. package/assets/skills/planning-guidance/SKILL.md +72 -13
  18. package/assets/skills/retrospective-analysis/SKILL.md +2 -2
  19. package/assets/skills/scaffold-guidance/SKILL.md +129 -124
  20. package/assets/skills/submission-packaging/SKILL.md +33 -27
  21. package/assets/skills/verification-gates/SKILL.md +44 -14
  22. package/assets/slopmachine/backend-evaluation-prompt.md +1 -1
  23. package/assets/slopmachine/frontend-evaluation-prompt.md +5 -5
  24. package/assets/slopmachine/scaffold-playbooks/android-kotlin-compose.md +81 -0
  25. package/assets/slopmachine/scaffold-playbooks/android-kotlin-views.md +191 -0
  26. package/assets/slopmachine/scaffold-playbooks/android-native-java.md +203 -0
  27. package/assets/slopmachine/scaffold-playbooks/angular-default.md +181 -0
  28. package/assets/slopmachine/scaffold-playbooks/backend-baseline.md +142 -0
  29. package/assets/slopmachine/scaffold-playbooks/backend-family-matrix.md +80 -0
  30. package/assets/slopmachine/scaffold-playbooks/database-module-matrix.md +80 -0
  31. package/assets/slopmachine/scaffold-playbooks/django-default.md +166 -0
  32. package/assets/slopmachine/scaffold-playbooks/docker-baseline.md +189 -0
  33. package/assets/slopmachine/scaffold-playbooks/docker-shared-contract.md +334 -0
  34. package/assets/slopmachine/scaffold-playbooks/electron-vite-default.md +124 -0
  35. package/assets/slopmachine/scaffold-playbooks/expo-react-native-default.md +73 -0
  36. package/assets/slopmachine/scaffold-playbooks/fastapi-default.md +134 -0
  37. package/assets/slopmachine/scaffold-playbooks/frontend-baseline.md +160 -0
  38. package/assets/slopmachine/scaffold-playbooks/frontend-family-matrix.md +134 -0
  39. package/assets/slopmachine/scaffold-playbooks/generic-unknown-tech-guide.md +136 -0
  40. package/assets/slopmachine/scaffold-playbooks/go-chi-default.md +160 -0
  41. package/assets/slopmachine/scaffold-playbooks/ios-linux-portable.md +93 -0
  42. package/assets/slopmachine/scaffold-playbooks/ios-native-objective-c.md +151 -0
  43. package/assets/slopmachine/scaffold-playbooks/ios-native-swift.md +188 -0
  44. package/assets/slopmachine/scaffold-playbooks/laravel-default.md +216 -0
  45. package/assets/slopmachine/scaffold-playbooks/livewire-default.md +265 -0
  46. package/assets/slopmachine/scaffold-playbooks/overlay-module-matrix.md +130 -0
  47. package/assets/slopmachine/scaffold-playbooks/platform-family-matrix.md +79 -0
  48. package/assets/slopmachine/scaffold-playbooks/selection-matrix.md +72 -0
  49. package/assets/slopmachine/scaffold-playbooks/spring-boot-default.md +182 -0
  50. package/assets/slopmachine/scaffold-playbooks/tauri-default.md +80 -0
  51. package/assets/slopmachine/scaffold-playbooks/vue-vite-default.md +162 -0
  52. package/assets/slopmachine/scaffold-playbooks/web-default.md +96 -0
  53. package/assets/slopmachine/templates/AGENTS.md +41 -3
  54. package/assets/slopmachine/templates/CLAUDE.md +111 -0
  55. package/assets/slopmachine/utils/claude_create_session.mjs +1 -0
  56. package/assets/slopmachine/utils/claude_live_channel.mjs +188 -0
  57. package/assets/slopmachine/utils/claude_live_common.mjs +406 -0
  58. package/assets/slopmachine/utils/claude_live_hook.py +47 -0
  59. package/assets/slopmachine/utils/claude_live_launch.mjs +181 -0
  60. package/assets/slopmachine/utils/claude_live_status.mjs +25 -0
  61. package/assets/slopmachine/utils/claude_live_stop.mjs +45 -0
  62. package/assets/slopmachine/utils/claude_live_turn.mjs +250 -0
  63. package/assets/slopmachine/utils/claude_resume_session.mjs +1 -0
  64. package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.mjs +23 -0
  65. package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.sh +5 -0
  66. package/assets/slopmachine/utils/claude_worker_common.mjs +224 -4
  67. package/assets/slopmachine/utils/cleanup_delivery_artifacts.py +4 -0
  68. package/assets/slopmachine/utils/export_ai_session.mjs +1 -1
  69. package/assets/slopmachine/utils/normalize_claude_session.py +153 -0
  70. package/assets/slopmachine/utils/package_claude_session.mjs +96 -0
  71. package/assets/slopmachine/utils/prepare_strict_audit_workspace.mjs +65 -0
  72. package/package.json +1 -1
  73. package/src/constants.js +42 -3
  74. package/src/init.js +173 -28
  75. package/src/install.js +75 -0
  76. package/src/send-data.js +56 -57
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises'
4
+ import http from 'node:http'
5
+ import net from 'node:net'
6
+ import path from 'node:path'
7
+ import crypto from 'node:crypto'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { spawn } from 'node:child_process'
10
+
11
+ import { emitFailure, emitSuccess, parseArgs, readJsonFile, readPrompt, sleep, waitForRateLimitReset, writeFileIfNeeded, writeJsonIfNeeded } from './claude_worker_common.mjs'
12
+
13
+ export { emitFailure, emitSuccess, parseArgs, readPrompt, sleep, waitForRateLimitReset, writeJsonIfNeeded }
14
+
15
+ export const DEFAULT_LAUNCH_TIMEOUT_MS = 3600000
16
+ export const DEFAULT_TURN_TIMEOUT_MS = 3600000
17
+ export const DEFAULT_CHANNEL_POST_TIMEOUT_MS = 10000
18
+ export const DEFAULT_POLL_INTERVAL_MS = 500
19
+
20
+ export function buildRuntimePaths(runtimeDir) {
21
+ const absoluteRuntimeDir = path.resolve(runtimeDir)
22
+ return {
23
+ runtimeDir: absoluteRuntimeDir,
24
+ stateFile: path.join(absoluteRuntimeDir, 'state.json'),
25
+ resultFile: path.join(absoluteRuntimeDir, 'result.json'),
26
+ settingsFile: path.join(absoluteRuntimeDir, 'settings.json'),
27
+ mcpConfigFile: path.join(absoluteRuntimeDir, 'mcp.json'),
28
+ channelStateFile: path.join(absoluteRuntimeDir, 'channel-state.json'),
29
+ channelLogFile: path.join(absoluteRuntimeDir, 'channel.log'),
30
+ hookEventsFile: path.join(absoluteRuntimeDir, 'hook-events.jsonl'),
31
+ turnsDir: path.join(absoluteRuntimeDir, 'turns'),
32
+ }
33
+ }
34
+
35
+ export function resolveUtilsDir() {
36
+ return path.dirname(fileURLToPath(import.meta.url))
37
+ }
38
+
39
+ export async function ensureRuntimeDirs(paths) {
40
+ await fs.mkdir(paths.runtimeDir, { recursive: true })
41
+ await fs.mkdir(paths.turnsDir, { recursive: true })
42
+ }
43
+
44
+ export async function readJsonIfExists(filePath) {
45
+ try {
46
+ return await readJsonFile(filePath)
47
+ } catch (error) {
48
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
49
+ return null
50
+ }
51
+ throw error
52
+ }
53
+ }
54
+
55
+ export async function readState(runtimeDir) {
56
+ return readJsonIfExists(buildRuntimePaths(runtimeDir).stateFile)
57
+ }
58
+
59
+ export async function writeState(runtimeDir, patch) {
60
+ const paths = buildRuntimePaths(runtimeDir)
61
+ const existing = (await readJsonIfExists(paths.stateFile)) || {}
62
+ const next = {
63
+ ...existing,
64
+ ...patch,
65
+ updated_at: new Date().toISOString(),
66
+ }
67
+ await writeJsonIfNeeded(paths.stateFile, next)
68
+ return next
69
+ }
70
+
71
+ export async function clearRuntimeArtifacts(paths) {
72
+ await writeFileIfNeeded(paths.hookEventsFile, '')
73
+ await writeFileIfNeeded(paths.channelLogFile, '')
74
+ await fs.rm(paths.channelStateFile, { force: true })
75
+ await fs.rm(paths.resultFile, { force: true })
76
+ }
77
+
78
+ export function sanitizeName(value) {
79
+ return String(value || 'lane')
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9]+/g, '-')
82
+ .replace(/^-+|-+$/g, '')
83
+ .slice(0, 40) || 'lane'
84
+ }
85
+
86
+ export function makeSuffix(size = 4) {
87
+ return crypto.randomBytes(size).toString('hex')
88
+ }
89
+
90
+ export function makeToken(size = 24) {
91
+ return crypto.randomBytes(size).toString('hex')
92
+ }
93
+
94
+ export function shellQuote(value) {
95
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
96
+ }
97
+
98
+ export async function allocatePort(host = '127.0.0.1') {
99
+ return new Promise((resolve, reject) => {
100
+ const server = net.createServer()
101
+ server.once('error', reject)
102
+ server.listen(0, host, () => {
103
+ const address = server.address()
104
+ const port = address && typeof address === 'object' ? address.port : null
105
+ server.close((error) => {
106
+ if (error) {
107
+ reject(error)
108
+ return
109
+ }
110
+ resolve(port)
111
+ })
112
+ })
113
+ })
114
+ }
115
+
116
+ export async function runCommand(command, args, options = {}) {
117
+ return new Promise((resolve, reject) => {
118
+ const child = spawn(command, args, {
119
+ cwd: options.cwd,
120
+ env: process.env,
121
+ stdio: ['ignore', 'pipe', 'pipe'],
122
+ })
123
+
124
+ let stdout = ''
125
+ let stderr = ''
126
+
127
+ child.stdout.on('data', (chunk) => {
128
+ stdout += chunk.toString()
129
+ })
130
+
131
+ child.stderr.on('data', (chunk) => {
132
+ stderr += chunk.toString()
133
+ })
134
+
135
+ child.on('error', reject)
136
+ child.on('close', (code) => {
137
+ resolve({ code: code ?? 1, stdout, stderr })
138
+ })
139
+ })
140
+ }
141
+
142
+ export async function tmuxHasSession(sessionName) {
143
+ if (!sessionName) return false
144
+ const result = await runCommand('tmux', ['has-session', '-t', sessionName])
145
+ return result.code === 0
146
+ }
147
+
148
+ export async function tmuxCapturePane(sessionName) {
149
+ const result = await runCommand('tmux', ['capture-pane', '-p', '-t', sessionName])
150
+ return result.code === 0 ? result.stdout : ''
151
+ }
152
+
153
+ export async function tmuxSendEnter(sessionName) {
154
+ return runCommand('tmux', ['send-keys', '-t', sessionName, 'Enter'])
155
+ }
156
+
157
+ export async function tmuxKillSession(sessionName) {
158
+ return runCommand('tmux', ['kill-session', '-t', sessionName])
159
+ }
160
+
161
+ export async function waitFor(predicate, { timeoutMs, intervalMs = DEFAULT_POLL_INTERVAL_MS, errorMessage }) {
162
+ const deadline = Date.now() + timeoutMs
163
+ while (Date.now() < deadline) {
164
+ const value = await predicate()
165
+ if (value) {
166
+ return value
167
+ }
168
+ await sleep(intervalMs)
169
+ }
170
+ throw new Error(errorMessage)
171
+ }
172
+
173
+ export async function maybeAcceptDevelopmentChannelsPrompt(sessionName, timeoutMs = 30000) {
174
+ const deadline = Date.now() + timeoutMs
175
+ while (Date.now() < deadline) {
176
+ const pane = await tmuxCapturePane(sessionName)
177
+ if (pane.includes('Listening for channel messages from:')) {
178
+ return false
179
+ }
180
+ if (pane.includes('I am using this for local development')) {
181
+ await tmuxSendEnter(sessionName)
182
+ return true
183
+ }
184
+ await sleep(DEFAULT_POLL_INTERVAL_MS)
185
+ }
186
+ return false
187
+ }
188
+
189
+ export async function waitForChannelReady(paths, timeoutMs) {
190
+ return waitFor(async () => {
191
+ const channelState = await readJsonIfExists(paths.channelStateFile)
192
+ if (channelState?.ready_for_notifications) {
193
+ return channelState
194
+ }
195
+ return null
196
+ }, {
197
+ timeoutMs,
198
+ errorMessage: 'Timed out waiting for Claude channel readiness',
199
+ })
200
+ }
201
+
202
+ export async function readJsonl(filePath) {
203
+ try {
204
+ const content = await fs.readFile(filePath, 'utf8')
205
+ return content
206
+ .split('\n')
207
+ .map((line) => line.trim())
208
+ .filter(Boolean)
209
+ .map((line) => JSON.parse(line))
210
+ } catch (error) {
211
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
212
+ return []
213
+ }
214
+ throw error
215
+ }
216
+ }
217
+
218
+ export async function waitForHookEvent(paths, startIndex, labels, timeoutMs) {
219
+ return waitFor(async () => {
220
+ const events = await readJsonl(paths.hookEventsFile)
221
+ const pending = events.slice(startIndex)
222
+ const match = pending.find((event) => labels.has(event.label))
223
+ if (!match) {
224
+ return null
225
+ }
226
+ return {
227
+ event: match,
228
+ eventsLength: events.length,
229
+ }
230
+ }, {
231
+ timeoutMs,
232
+ errorMessage: `Timed out waiting for hook event: ${Array.from(labels).join(', ')}`,
233
+ })
234
+ }
235
+
236
+ export function buildHookSettings({ runtimeDir, utilsDir, agentName = 'developer', subagentModel = null }) {
237
+ const buildCommand = (label) => `python3 ${shellQuote(path.join(utilsDir, 'claude_live_hook.py'))} --runtime-dir ${shellQuote(runtimeDir)} ${label}`
238
+ const settings = {
239
+ agent: agentName,
240
+ hooks: {
241
+ SessionStart: [{ hooks: [{ type: 'command', command: buildCommand('SessionStart') }] }],
242
+ UserPromptSubmit: [{ hooks: [{ type: 'command', command: buildCommand('UserPromptSubmit') }] }],
243
+ Stop: [{ hooks: [{ type: 'command', command: buildCommand('Stop') }] }],
244
+ StopFailure: [{ hooks: [{ type: 'command', command: buildCommand('StopFailure') }] }],
245
+ },
246
+ }
247
+
248
+ if (subagentModel) {
249
+ settings.env = {
250
+ CLAUDE_CODE_SUBAGENT_MODEL: subagentModel,
251
+ }
252
+ }
253
+
254
+ return settings
255
+ }
256
+
257
+ export function buildMcpConfig({ paths, utilsDir, channelName, lane, port, token }) {
258
+ return {
259
+ mcpServers: {
260
+ [channelName]: {
261
+ command: 'node',
262
+ args: [
263
+ path.join(utilsDir, 'claude_live_channel.mjs'),
264
+ '--port',
265
+ String(port),
266
+ '--token',
267
+ token,
268
+ '--state-file',
269
+ paths.channelStateFile,
270
+ '--log-file',
271
+ paths.channelLogFile,
272
+ '--channel-name',
273
+ channelName,
274
+ '--lane',
275
+ lane,
276
+ ],
277
+ },
278
+ },
279
+ }
280
+ }
281
+
282
+ export function buildClaudeLaunchCommand({ claudeCommand, agentName, displayName, settingsFile, mcpConfigFile, channelName, model }) {
283
+ const parts = [
284
+ shellQuote(claudeCommand),
285
+ '--agent',
286
+ shellQuote(agentName),
287
+ '-n',
288
+ shellQuote(displayName),
289
+ '--settings',
290
+ shellQuote(settingsFile),
291
+ '--mcp-config',
292
+ shellQuote(mcpConfigFile),
293
+ '--dangerously-skip-permissions',
294
+ '--dangerously-load-development-channels',
295
+ shellQuote(`server:${channelName}`),
296
+ ]
297
+
298
+ if (model) {
299
+ parts.push('--model', shellQuote(model))
300
+ }
301
+
302
+ return parts.join(' ')
303
+ }
304
+
305
+ export async function postPromptToChannel({ state, prompt, turnId, timeoutMs = DEFAULT_CHANNEL_POST_TIMEOUT_MS }) {
306
+ return new Promise((resolve, reject) => {
307
+ const request = http.request({
308
+ hostname: '127.0.0.1',
309
+ port: state.channel_port,
310
+ path: '/',
311
+ method: 'POST',
312
+ headers: {
313
+ 'content-type': 'text/plain; charset=utf-8',
314
+ 'content-length': Buffer.byteLength(prompt, 'utf8'),
315
+ 'x-bridge-token': state.channel_token,
316
+ 'x-lane': state.lane,
317
+ 'x-turn-id': turnId,
318
+ },
319
+ timeout: timeoutMs,
320
+ }, (response) => {
321
+ let body = ''
322
+ response.on('data', (chunk) => {
323
+ body += chunk.toString()
324
+ })
325
+ response.on('end', () => {
326
+ resolve({
327
+ statusCode: response.statusCode || 0,
328
+ body,
329
+ })
330
+ })
331
+ })
332
+
333
+ request.on('error', reject)
334
+ request.on('timeout', () => {
335
+ request.destroy(new Error('channel_post_timeout'))
336
+ })
337
+ request.write(prompt)
338
+ request.end()
339
+ })
340
+ }
341
+
342
+ export function buildTurnId(lastTurnNumber) {
343
+ return String(lastTurnNumber + 1).padStart(4, '0')
344
+ }
345
+
346
+ export async function writeTurnArtifacts(paths, turnId, prompt, resultPayload, extraResultFile = null) {
347
+ const turnDir = path.join(paths.turnsDir, turnId)
348
+ await fs.mkdir(turnDir, { recursive: true })
349
+ await writeFileIfNeeded(path.join(turnDir, 'prompt.txt'), `${prompt}\n`)
350
+ await writeJsonIfNeeded(path.join(turnDir, 'result.json'), resultPayload)
351
+ await writeJsonIfNeeded(paths.resultFile, resultPayload)
352
+ if (extraResultFile) {
353
+ await writeJsonIfNeeded(extraResultFile, resultPayload)
354
+ }
355
+ return turnDir
356
+ }
357
+
358
+ export function extractFailureMessage(payload) {
359
+ if (!payload) return ''
360
+
361
+ const candidates = [
362
+ payload.message,
363
+ payload.error,
364
+ payload.failure_reason,
365
+ payload.reason,
366
+ payload.details,
367
+ ]
368
+
369
+ for (const candidate of candidates) {
370
+ if (typeof candidate === 'string' && candidate.trim()) {
371
+ return candidate.trim()
372
+ }
373
+ if (candidate && typeof candidate === 'object') {
374
+ return JSON.stringify(candidate)
375
+ }
376
+ }
377
+
378
+ return JSON.stringify(payload)
379
+ }
380
+
381
+ export function classifyStopFailure(event, fallbackSid = null) {
382
+ const payload = event?.payload || null
383
+ const sid = payload?.session_id || fallbackSid || null
384
+ const message = extractFailureMessage(payload) || 'claude_stop_failure'
385
+
386
+ if (/hit your limit|usage limit|capacity|overloaded/i.test(message)) {
387
+ return {
388
+ result: { ok: false, code: 'claude_usage_limit', msg: 'usage_limit', detail: message, sid },
389
+ nextStatus: 'blocked',
390
+ }
391
+ }
392
+
393
+ return {
394
+ result: { ok: false, code: 'claude_stop_failure', msg: message, sid },
395
+ nextStatus: 'failed',
396
+ }
397
+ }
398
+
399
+ export function buildFailureResult(code, message, sid = null) {
400
+ return {
401
+ ok: false,
402
+ code,
403
+ msg: message,
404
+ sid,
405
+ }
406
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ import sys
7
+
8
+
9
+ def parse_args(argv: list[str]) -> tuple[Path, str]:
10
+ runtime_dir = None
11
+ label = 'unknown'
12
+ index = 0
13
+ while index < len(argv):
14
+ value = argv[index]
15
+ if value == '--runtime-dir':
16
+ index += 1
17
+ runtime_dir = Path(argv[index])
18
+ else:
19
+ label = value
20
+ index += 1
21
+ if runtime_dir is None:
22
+ raise SystemExit('Missing --runtime-dir')
23
+ return runtime_dir, label
24
+
25
+
26
+ def main() -> None:
27
+ runtime_dir, label = parse_args(sys.argv[1:])
28
+ payload_raw = sys.stdin.read().strip()
29
+ try:
30
+ payload = json.loads(payload_raw) if payload_raw else None
31
+ except Exception:
32
+ payload = {'raw': payload_raw}
33
+
34
+ record = {
35
+ 'ts': datetime.now(timezone.utc).isoformat(),
36
+ 'label': label,
37
+ 'payload': payload,
38
+ }
39
+
40
+ runtime_dir.mkdir(parents=True, exist_ok=True)
41
+ hook_log = runtime_dir / 'hook-events.jsonl'
42
+ with hook_log.open('a', encoding='utf-8') as handle:
43
+ handle.write(json.dumps(record) + '\n')
44
+
45
+
46
+ if __name__ == '__main__':
47
+ main()
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'node:path'
4
+
5
+ import {
6
+ DEFAULT_LAUNCH_TIMEOUT_MS,
7
+ allocatePort,
8
+ buildClaudeLaunchCommand,
9
+ buildHookSettings,
10
+ buildMcpConfig,
11
+ buildRuntimePaths,
12
+ clearRuntimeArtifacts,
13
+ emitFailure,
14
+ emitSuccess,
15
+ ensureRuntimeDirs,
16
+ makeSuffix,
17
+ makeToken,
18
+ maybeAcceptDevelopmentChannelsPrompt,
19
+ parseArgs,
20
+ readJsonIfExists,
21
+ resolveUtilsDir,
22
+ runCommand,
23
+ sanitizeName,
24
+ tmuxHasSession,
25
+ tmuxKillSession,
26
+ waitForChannelReady,
27
+ waitForHookEvent,
28
+ writeJsonIfNeeded,
29
+ writeState,
30
+ } from './claude_live_common.mjs'
31
+
32
+ const argv = parseArgs(process.argv.slice(2))
33
+
34
+ const runtimeDir = argv['runtime-dir']
35
+ const cwd = argv.cwd ? path.resolve(argv.cwd) : null
36
+ const lane = argv.lane
37
+ const agentName = argv.agent || 'developer'
38
+ const claudeCommand = argv['claude-command'] || 'claude'
39
+ const launchTimeoutMs = Number.parseInt(argv['timeout-ms'] || String(DEFAULT_LAUNCH_TIMEOUT_MS), 10)
40
+ const replace = argv.replace === '1'
41
+
42
+ if (!runtimeDir || !lane || !cwd) {
43
+ emitFailure('claude_live_launch_invalid_args', 'Missing required launch arguments', {
44
+ state_file: runtimeDir ? buildRuntimePaths(runtimeDir).stateFile : null,
45
+ })
46
+ process.exit(1)
47
+ }
48
+
49
+ const paths = buildRuntimePaths(runtimeDir)
50
+
51
+ try {
52
+ await ensureRuntimeDirs(paths)
53
+
54
+ const existingState = await readJsonIfExists(paths.stateFile)
55
+ if (existingState?.tmux_session && await tmuxHasSession(existingState.tmux_session)) {
56
+ if (!replace) {
57
+ emitSuccess(existingState.sid || null, {
58
+ state_file: paths.stateFile,
59
+ result_file: paths.resultFile,
60
+ })
61
+ process.exit(0)
62
+ }
63
+ await tmuxKillSession(existingState.tmux_session)
64
+ } else if (existingState && !replace && existingState.status && existingState.status !== 'stopped') {
65
+ emitFailure('claude_live_state_stale', 'Existing runtime state is stale. Re-run with --replace 1 to recover it.', {
66
+ sid: existingState.sid || null,
67
+ state_file: paths.stateFile,
68
+ result_file: paths.resultFile,
69
+ })
70
+ process.exit(1)
71
+ }
72
+
73
+ const suffix = makeSuffix()
74
+ const tmuxSession = argv['tmux-session'] || `sm-${sanitizeName(lane)}-${suffix}`
75
+ const channelName = argv['channel-name'] || `slopmachine-${sanitizeName(lane)}-${suffix}`
76
+ const channelPort = await allocatePort()
77
+ const channelToken = makeToken()
78
+ const utilsDir = resolveUtilsDir()
79
+
80
+ await clearRuntimeArtifacts(paths)
81
+ await writeState(runtimeDir, {
82
+ lane,
83
+ backend: 'claude-live',
84
+ status: 'starting',
85
+ cwd,
86
+ sid: null,
87
+ agent_name: agentName,
88
+ model: argv.model || null,
89
+ tmux_session: tmuxSession,
90
+ channel_name: channelName,
91
+ channel_port: channelPort,
92
+ channel_token: channelToken,
93
+ runtime_dir: paths.runtimeDir,
94
+ settings_file: paths.settingsFile,
95
+ mcp_config_file: paths.mcpConfigFile,
96
+ hook_events_file: paths.hookEventsFile,
97
+ channel_state_file: paths.channelStateFile,
98
+ result_file: paths.resultFile,
99
+ transcript_path: null,
100
+ current_turn_id: null,
101
+ last_completed_turn_id: null,
102
+ last_hook_event: null,
103
+ last_error: null,
104
+ last_turn_number: 0,
105
+ started_at: new Date().toISOString(),
106
+ })
107
+
108
+ await Promise.all([
109
+ writeJsonIfNeeded(paths.settingsFile, buildHookSettings({
110
+ runtimeDir: paths.runtimeDir,
111
+ utilsDir,
112
+ agentName,
113
+ subagentModel: argv.model || 'sonnet',
114
+ })),
115
+ writeJsonIfNeeded(paths.mcpConfigFile, buildMcpConfig({
116
+ paths,
117
+ utilsDir,
118
+ channelName,
119
+ lane,
120
+ port: channelPort,
121
+ token: channelToken,
122
+ })),
123
+ ])
124
+
125
+ const launchCommand = buildClaudeLaunchCommand({
126
+ claudeCommand,
127
+ agentName,
128
+ displayName: lane,
129
+ settingsFile: paths.settingsFile,
130
+ mcpConfigFile: paths.mcpConfigFile,
131
+ channelName,
132
+ model: argv.model || null,
133
+ })
134
+
135
+ const launchResult = await runCommand('tmux', ['new-session', '-d', '-s', tmuxSession, '-c', cwd, launchCommand])
136
+ if (launchResult.code !== 0) {
137
+ emitFailure('claude_live_launch_failed', launchResult.stderr.trim() || 'Failed to start tmux Claude session', {
138
+ state_file: paths.stateFile,
139
+ result_file: paths.resultFile,
140
+ })
141
+ process.exit(1)
142
+ }
143
+
144
+ await maybeAcceptDevelopmentChannelsPrompt(tmuxSession, Math.min(launchTimeoutMs, 30000))
145
+
146
+ const [{ event }, channelState] = await Promise.all([
147
+ waitForHookEvent(paths, 0, new Set(['SessionStart']), launchTimeoutMs),
148
+ waitForChannelReady(paths, launchTimeoutMs),
149
+ ])
150
+
151
+ const sessionId = event.payload?.session_id || null
152
+ const transcriptPath = event.payload?.transcript_path || null
153
+ await writeState(runtimeDir, {
154
+ status: 'idle',
155
+ sid: sessionId,
156
+ transcript_path: transcriptPath,
157
+ last_hook_event: 'SessionStart',
158
+ last_error: null,
159
+ channel_status: channelState.status || 'ready',
160
+ })
161
+
162
+ emitSuccess(sessionId, {
163
+ state_file: paths.stateFile,
164
+ result_file: paths.resultFile,
165
+ })
166
+ } catch (error) {
167
+ const state = await readJsonIfExists(paths.stateFile)
168
+ if (state?.tmux_session && await tmuxHasSession(state.tmux_session)) {
169
+ await tmuxKillSession(state.tmux_session)
170
+ }
171
+ await writeState(runtimeDir, {
172
+ status: 'failed',
173
+ last_error: error instanceof Error ? error.message : String(error),
174
+ })
175
+ emitFailure('claude_live_launch_failed', error instanceof Error ? error.message : String(error), {
176
+ sid: state?.sid || null,
177
+ state_file: paths.stateFile,
178
+ result_file: paths.resultFile,
179
+ })
180
+ process.exit(1)
181
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { buildRuntimePaths, parseArgs, readState, tmuxHasSession } from './claude_live_common.mjs'
4
+
5
+ const argv = parseArgs(process.argv.slice(2))
6
+ const runtimeDir = argv['runtime-dir']
7
+
8
+ if (!runtimeDir) {
9
+ process.stdout.write(JSON.stringify({ ok: false, code: 'claude_live_status_invalid_args', msg: 'Missing --runtime-dir' }))
10
+ process.exit(1)
11
+ }
12
+
13
+ const state = await readState(runtimeDir)
14
+ if (!state) {
15
+ process.stdout.write(JSON.stringify({ ok: false, code: 'claude_live_status_missing', msg: 'Missing bridge state', state_file: buildRuntimePaths(runtimeDir).stateFile }))
16
+ process.exit(1)
17
+ }
18
+
19
+ const tmuxAlive = await tmuxHasSession(state.tmux_session)
20
+ const { channel_token: _channelToken, ...safeState } = state
21
+ process.stdout.write(JSON.stringify({
22
+ ok: true,
23
+ ...safeState,
24
+ tmux_alive: tmuxAlive,
25
+ }))
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { buildRuntimePaths, emitFailure, emitSuccess, parseArgs, readState, tmuxHasSession, tmuxKillSession, writeState } from './claude_live_common.mjs'
4
+
5
+ const argv = parseArgs(process.argv.slice(2))
6
+ const runtimeDir = argv['runtime-dir']
7
+
8
+ if (!runtimeDir) {
9
+ emitFailure('claude_live_stop_invalid_args', 'Missing --runtime-dir')
10
+ process.exit(1)
11
+ }
12
+
13
+ const paths = buildRuntimePaths(runtimeDir)
14
+ const state = await readState(runtimeDir)
15
+
16
+ if (!state) {
17
+ emitFailure('claude_live_stop_missing', 'Missing bridge state', { state_file: paths.stateFile })
18
+ process.exit(1)
19
+ }
20
+
21
+ if (state.tmux_session && await tmuxHasSession(state.tmux_session)) {
22
+ const stopResult = await tmuxKillSession(state.tmux_session)
23
+ if (stopResult.code !== 0) {
24
+ emitFailure('claude_live_stop_failed', stopResult.stderr.trim() || 'Failed to stop tmux session', {
25
+ sid: state.sid || null,
26
+ state_file: paths.stateFile,
27
+ result_file: paths.resultFile,
28
+ })
29
+ process.exit(1)
30
+ }
31
+ }
32
+
33
+ await writeState(runtimeDir, {
34
+ status: 'stopped',
35
+ current_turn_id: null,
36
+ current_turn_prompt_file: null,
37
+ current_turn_started_at: null,
38
+ last_error: null,
39
+ stopped_at: new Date().toISOString(),
40
+ })
41
+
42
+ emitSuccess(state.sid || null, {
43
+ state_file: paths.stateFile,
44
+ result_file: paths.resultFile,
45
+ })