theslopmachine 0.6.2 → 0.7.1
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 +16 -1
- package/assets/agents/developer.md +41 -1
- package/assets/agents/slopmachine-claude.md +101 -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 +290 -57
- package/assets/skills/developer-session-lifecycle/SKILL.md +83 -38
- package/assets/skills/development-guidance/SKILL.md +21 -1
- package/assets/skills/evaluation-triage/SKILL.md +34 -23
- package/assets/skills/final-evaluation-orchestration/SKILL.md +88 -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/test-coverage-prompt.md +561 -0
- package/assets/slopmachine/utils/claude_create_session.mjs +3 -2
- package/assets/slopmachine/utils/claude_live_channel.mjs +188 -0
- package/assets/slopmachine/utils/claude_live_common.mjs +411 -0
- package/assets/slopmachine/utils/claude_live_hook.py +47 -0
- package/assets/slopmachine/utils/claude_live_launch.mjs +187 -0
- package/assets/slopmachine/utils/claude_live_status.mjs +25 -0
- package/assets/slopmachine/utils/claude_live_stop.mjs +46 -0
- package/assets/slopmachine/utils/claude_live_turn.mjs +277 -0
- package/assets/slopmachine/utils/claude_resume_session.mjs +3 -2
- 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 +361 -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 +123 -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 +156 -8
- package/src/send-data.js +56 -57
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import http from 'node:http'
|
|
4
|
+
import fs from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { parseArgs, writeJsonIfNeeded } from './claude_worker_common.mjs'
|
|
8
|
+
|
|
9
|
+
const argv = parseArgs(process.argv.slice(2))
|
|
10
|
+
const port = Number.parseInt(argv.port, 10)
|
|
11
|
+
const token = argv.token
|
|
12
|
+
const stateFile = argv['state-file']
|
|
13
|
+
const logFile = argv['log-file']
|
|
14
|
+
const channelName = argv['channel-name'] || 'slopmachine-channel'
|
|
15
|
+
const lane = argv.lane || 'lane'
|
|
16
|
+
|
|
17
|
+
const instructions = 'Messages arrive as <channel source="slopmachine" ...>. Treat them as ordinary inbound work requests and respond normally in the current Claude session. Do not look for or use any reply tool for this channel.'
|
|
18
|
+
|
|
19
|
+
let buffer = ''
|
|
20
|
+
let readyForNotifications = false
|
|
21
|
+
let negotiatedProtocolVersion = '2025-11-25'
|
|
22
|
+
|
|
23
|
+
function writeMessage(message) {
|
|
24
|
+
process.stdout.write(`${JSON.stringify(message)}\n`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function appendLog(record) {
|
|
28
|
+
const line = `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`
|
|
29
|
+
await fs.mkdir(path.dirname(logFile), { recursive: true })
|
|
30
|
+
await fs.appendFile(logFile, line, 'utf8')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function updateState(patch) {
|
|
34
|
+
await writeJsonIfNeeded(stateFile, {
|
|
35
|
+
channel_name: channelName,
|
|
36
|
+
lane,
|
|
37
|
+
pid: process.pid,
|
|
38
|
+
port,
|
|
39
|
+
ready_for_notifications: readyForNotifications,
|
|
40
|
+
protocol_version: negotiatedProtocolVersion,
|
|
41
|
+
updated_at: new Date().toISOString(),
|
|
42
|
+
...patch,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function handleRpc(message) {
|
|
47
|
+
if (message.method === 'initialize' && Object.prototype.hasOwnProperty.call(message, 'id')) {
|
|
48
|
+
negotiatedProtocolVersion = message.params?.protocolVersion || negotiatedProtocolVersion
|
|
49
|
+
await updateState({ status: 'initialized' })
|
|
50
|
+
writeMessage({
|
|
51
|
+
jsonrpc: '2.0',
|
|
52
|
+
id: message.id,
|
|
53
|
+
result: {
|
|
54
|
+
protocolVersion: negotiatedProtocolVersion,
|
|
55
|
+
capabilities: {
|
|
56
|
+
experimental: {
|
|
57
|
+
'claude/channel': {},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
serverInfo: {
|
|
61
|
+
name: channelName,
|
|
62
|
+
version: '0.1.0',
|
|
63
|
+
},
|
|
64
|
+
instructions,
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
await appendLog({ type: 'initialize', message })
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (message.method === 'notifications/initialized') {
|
|
72
|
+
readyForNotifications = true
|
|
73
|
+
await updateState({ status: 'ready' })
|
|
74
|
+
await appendLog({ type: 'initialized' })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (message.method === 'ping' && Object.prototype.hasOwnProperty.call(message, 'id')) {
|
|
79
|
+
writeMessage({ jsonrpc: '2.0', id: message.id, result: {} })
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (Object.prototype.hasOwnProperty.call(message, 'id')) {
|
|
84
|
+
writeMessage({
|
|
85
|
+
jsonrpc: '2.0',
|
|
86
|
+
id: message.id,
|
|
87
|
+
error: {
|
|
88
|
+
code: -32601,
|
|
89
|
+
message: `Method not found: ${message.method}`,
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
process.stdin.setEncoding('utf8')
|
|
96
|
+
process.stdin.on('data', async (chunk) => {
|
|
97
|
+
buffer += chunk
|
|
98
|
+
while (true) {
|
|
99
|
+
const newlineIndex = buffer.indexOf('\n')
|
|
100
|
+
if (newlineIndex === -1) {
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
const line = buffer.slice(0, newlineIndex).trim()
|
|
104
|
+
buffer = buffer.slice(newlineIndex + 1)
|
|
105
|
+
if (!line) {
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const message = JSON.parse(line)
|
|
110
|
+
await handleRpc(message)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
await updateState({ status: 'failed', last_error: error instanceof Error ? error.message : String(error) })
|
|
113
|
+
await appendLog({ type: 'rpc_error', error: error instanceof Error ? error.message : String(error) })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
process.stdin.on('end', async () => {
|
|
119
|
+
await updateState({ status: 'closed' })
|
|
120
|
+
process.exit(0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
await updateState({ status: 'starting' })
|
|
124
|
+
|
|
125
|
+
const server = http.createServer(async (request, response) => {
|
|
126
|
+
if (request.method !== 'POST') {
|
|
127
|
+
response.writeHead(404)
|
|
128
|
+
response.end('not found')
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (request.headers['x-bridge-token'] !== token) {
|
|
133
|
+
response.writeHead(403)
|
|
134
|
+
response.end('forbidden')
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!readyForNotifications) {
|
|
139
|
+
response.writeHead(503)
|
|
140
|
+
response.end('channel_not_ready')
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let body = ''
|
|
145
|
+
for await (const chunk of request) {
|
|
146
|
+
body += chunk.toString()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const turnId = String(request.headers['x-turn-id'] || '').trim()
|
|
150
|
+
const notification = {
|
|
151
|
+
jsonrpc: '2.0',
|
|
152
|
+
method: 'notifications/claude/channel',
|
|
153
|
+
params: {
|
|
154
|
+
content: body,
|
|
155
|
+
meta: {
|
|
156
|
+
lane,
|
|
157
|
+
turn_id: turnId,
|
|
158
|
+
method: request.method,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
writeMessage(notification)
|
|
164
|
+
await updateState({ status: 'ready', last_message_at: new Date().toISOString(), last_turn_id: turnId || null })
|
|
165
|
+
await appendLog({ type: 'notification', turn_id: turnId || null, body })
|
|
166
|
+
response.writeHead(200)
|
|
167
|
+
response.end('ok')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
server.listen(port, '127.0.0.1', async () => {
|
|
171
|
+
await updateState({ status: 'listening' })
|
|
172
|
+
await appendLog({ type: 'listening' })
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
async function shutdown(code) {
|
|
176
|
+
await updateState({ status: 'closed' })
|
|
177
|
+
server.close(() => {
|
|
178
|
+
process.exit(code)
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
process.on('SIGINT', () => {
|
|
183
|
+
void shutdown(0)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
process.on('SIGTERM', () => {
|
|
187
|
+
void shutdown(0)
|
|
188
|
+
})
|
|
@@ -0,0 +1,411 @@
|
|
|
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, extractRateLimitMetadata, 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, effort = null }) {
|
|
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
|
+
if (effort) {
|
|
303
|
+
parts.push('--effort', shellQuote(effort))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return parts.join(' ')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function postPromptToChannel({ state, prompt, turnId, timeoutMs = DEFAULT_CHANNEL_POST_TIMEOUT_MS }) {
|
|
310
|
+
return new Promise((resolve, reject) => {
|
|
311
|
+
const request = http.request({
|
|
312
|
+
hostname: '127.0.0.1',
|
|
313
|
+
port: state.channel_port,
|
|
314
|
+
path: '/',
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: {
|
|
317
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
318
|
+
'content-length': Buffer.byteLength(prompt, 'utf8'),
|
|
319
|
+
'x-bridge-token': state.channel_token,
|
|
320
|
+
'x-lane': state.lane,
|
|
321
|
+
'x-turn-id': turnId,
|
|
322
|
+
},
|
|
323
|
+
timeout: timeoutMs,
|
|
324
|
+
}, (response) => {
|
|
325
|
+
let body = ''
|
|
326
|
+
response.on('data', (chunk) => {
|
|
327
|
+
body += chunk.toString()
|
|
328
|
+
})
|
|
329
|
+
response.on('end', () => {
|
|
330
|
+
resolve({
|
|
331
|
+
statusCode: response.statusCode || 0,
|
|
332
|
+
body,
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
request.on('error', reject)
|
|
338
|
+
request.on('timeout', () => {
|
|
339
|
+
request.destroy(new Error('channel_post_timeout'))
|
|
340
|
+
})
|
|
341
|
+
request.write(prompt)
|
|
342
|
+
request.end()
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function buildTurnId(lastTurnNumber) {
|
|
347
|
+
return String(lastTurnNumber + 1).padStart(4, '0')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function writeTurnArtifacts(paths, turnId, prompt, resultPayload, extraResultFile = null) {
|
|
351
|
+
const turnDir = path.join(paths.turnsDir, turnId)
|
|
352
|
+
await fs.mkdir(turnDir, { recursive: true })
|
|
353
|
+
await writeFileIfNeeded(path.join(turnDir, 'prompt.txt'), `${prompt}\n`)
|
|
354
|
+
await writeJsonIfNeeded(path.join(turnDir, 'result.json'), resultPayload)
|
|
355
|
+
await writeJsonIfNeeded(paths.resultFile, resultPayload)
|
|
356
|
+
if (extraResultFile) {
|
|
357
|
+
await writeJsonIfNeeded(extraResultFile, resultPayload)
|
|
358
|
+
}
|
|
359
|
+
return turnDir
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function extractFailureMessage(payload) {
|
|
363
|
+
if (!payload) return ''
|
|
364
|
+
|
|
365
|
+
const candidates = [
|
|
366
|
+
payload.message,
|
|
367
|
+
payload.error,
|
|
368
|
+
payload.failure_reason,
|
|
369
|
+
payload.reason,
|
|
370
|
+
payload.details,
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
for (const candidate of candidates) {
|
|
374
|
+
if (typeof candidate === 'string' && candidate.trim()) {
|
|
375
|
+
return candidate.trim()
|
|
376
|
+
}
|
|
377
|
+
if (candidate && typeof candidate === 'object') {
|
|
378
|
+
return JSON.stringify(candidate)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return JSON.stringify(payload)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function classifyStopFailure(event, fallbackSid = null) {
|
|
386
|
+
const payload = event?.payload || null
|
|
387
|
+
const sid = payload?.session_id || fallbackSid || null
|
|
388
|
+
const message = extractFailureMessage(payload) || 'claude_stop_failure'
|
|
389
|
+
const rateLimit = extractRateLimitMetadata(payload)
|
|
390
|
+
|
|
391
|
+
if (/hit your limit|usage limit|capacity|overloaded/i.test(message)) {
|
|
392
|
+
return {
|
|
393
|
+
result: { ok: false, code: 'claude_usage_limit', msg: 'usage_limit', detail: message, rate_limit: rateLimit, sid },
|
|
394
|
+
nextStatus: 'blocked',
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
result: { ok: false, code: 'claude_stop_failure', msg: message, sid },
|
|
400
|
+
nextStatus: 'failed',
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function buildFailureResult(code, message, sid = null) {
|
|
405
|
+
return {
|
|
406
|
+
ok: false,
|
|
407
|
+
code,
|
|
408
|
+
msg: message,
|
|
409
|
+
sid,
|
|
410
|
+
}
|
|
411
|
+
}
|
|
@@ -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()
|