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.
- package/MANUAL.md +21 -6
- package/README.md +55 -7
- package/RELEASE.md +15 -0
- package/assets/agents/developer.md +41 -1
- package/assets/agents/slopmachine-claude.md +100 -60
- package/assets/agents/slopmachine.md +40 -17
- package/assets/claude/agents/developer.md +42 -5
- package/assets/skills/clarification-gate/SKILL.md +25 -5
- package/assets/skills/claude-worker-management/SKILL.md +280 -57
- package/assets/skills/developer-session-lifecycle/SKILL.md +81 -37
- package/assets/skills/development-guidance/SKILL.md +21 -1
- package/assets/skills/evaluation-triage/SKILL.md +32 -23
- package/assets/skills/final-evaluation-orchestration/SKILL.md +86 -50
- package/assets/skills/hardening-gate/SKILL.md +17 -3
- package/assets/skills/integrated-verification/SKILL.md +3 -3
- package/assets/skills/planning-gate/SKILL.md +32 -3
- package/assets/skills/planning-guidance/SKILL.md +72 -13
- package/assets/skills/retrospective-analysis/SKILL.md +2 -2
- package/assets/skills/scaffold-guidance/SKILL.md +129 -124
- package/assets/skills/submission-packaging/SKILL.md +33 -27
- package/assets/skills/verification-gates/SKILL.md +44 -14
- package/assets/slopmachine/backend-evaluation-prompt.md +1 -1
- package/assets/slopmachine/frontend-evaluation-prompt.md +5 -5
- package/assets/slopmachine/scaffold-playbooks/android-kotlin-compose.md +81 -0
- package/assets/slopmachine/scaffold-playbooks/android-kotlin-views.md +191 -0
- package/assets/slopmachine/scaffold-playbooks/android-native-java.md +203 -0
- package/assets/slopmachine/scaffold-playbooks/angular-default.md +181 -0
- package/assets/slopmachine/scaffold-playbooks/backend-baseline.md +142 -0
- package/assets/slopmachine/scaffold-playbooks/backend-family-matrix.md +80 -0
- package/assets/slopmachine/scaffold-playbooks/database-module-matrix.md +80 -0
- package/assets/slopmachine/scaffold-playbooks/django-default.md +166 -0
- package/assets/slopmachine/scaffold-playbooks/docker-baseline.md +189 -0
- package/assets/slopmachine/scaffold-playbooks/docker-shared-contract.md +334 -0
- package/assets/slopmachine/scaffold-playbooks/electron-vite-default.md +124 -0
- package/assets/slopmachine/scaffold-playbooks/expo-react-native-default.md +73 -0
- package/assets/slopmachine/scaffold-playbooks/fastapi-default.md +134 -0
- package/assets/slopmachine/scaffold-playbooks/frontend-baseline.md +160 -0
- package/assets/slopmachine/scaffold-playbooks/frontend-family-matrix.md +134 -0
- package/assets/slopmachine/scaffold-playbooks/generic-unknown-tech-guide.md +136 -0
- package/assets/slopmachine/scaffold-playbooks/go-chi-default.md +160 -0
- package/assets/slopmachine/scaffold-playbooks/ios-linux-portable.md +93 -0
- package/assets/slopmachine/scaffold-playbooks/ios-native-objective-c.md +151 -0
- package/assets/slopmachine/scaffold-playbooks/ios-native-swift.md +188 -0
- package/assets/slopmachine/scaffold-playbooks/laravel-default.md +216 -0
- package/assets/slopmachine/scaffold-playbooks/livewire-default.md +265 -0
- package/assets/slopmachine/scaffold-playbooks/overlay-module-matrix.md +130 -0
- package/assets/slopmachine/scaffold-playbooks/platform-family-matrix.md +79 -0
- package/assets/slopmachine/scaffold-playbooks/selection-matrix.md +72 -0
- package/assets/slopmachine/scaffold-playbooks/spring-boot-default.md +182 -0
- package/assets/slopmachine/scaffold-playbooks/tauri-default.md +80 -0
- package/assets/slopmachine/scaffold-playbooks/vue-vite-default.md +162 -0
- package/assets/slopmachine/scaffold-playbooks/web-default.md +96 -0
- package/assets/slopmachine/templates/AGENTS.md +41 -3
- package/assets/slopmachine/templates/CLAUDE.md +111 -0
- package/assets/slopmachine/utils/claude_create_session.mjs +1 -0
- package/assets/slopmachine/utils/claude_live_channel.mjs +188 -0
- package/assets/slopmachine/utils/claude_live_common.mjs +406 -0
- package/assets/slopmachine/utils/claude_live_hook.py +47 -0
- package/assets/slopmachine/utils/claude_live_launch.mjs +181 -0
- package/assets/slopmachine/utils/claude_live_status.mjs +25 -0
- package/assets/slopmachine/utils/claude_live_stop.mjs +45 -0
- package/assets/slopmachine/utils/claude_live_turn.mjs +250 -0
- package/assets/slopmachine/utils/claude_resume_session.mjs +1 -0
- package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.mjs +23 -0
- package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.sh +5 -0
- package/assets/slopmachine/utils/claude_worker_common.mjs +224 -4
- package/assets/slopmachine/utils/cleanup_delivery_artifacts.py +4 -0
- package/assets/slopmachine/utils/export_ai_session.mjs +1 -1
- package/assets/slopmachine/utils/normalize_claude_session.py +153 -0
- package/assets/slopmachine/utils/package_claude_session.mjs +96 -0
- package/assets/slopmachine/utils/prepare_strict_audit_workspace.mjs +65 -0
- package/package.json +1 -1
- package/src/constants.js +42 -3
- package/src/init.js +173 -28
- package/src/install.js +75 -0
- 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
|
+
})
|