theslopmachine 1.0.6 → 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.
- package/assets/skills/claude-worker-management/SKILL.md +8 -6
- package/assets/slopmachine/utils/README.md +5 -21
- package/assets/slopmachine/utils/claude_live_common.mjs +78 -111
- package/assets/slopmachine/utils/claude_live_launch.mjs +23 -69
- package/assets/slopmachine/utils/claude_live_status.mjs +5 -14
- package/assets/slopmachine/utils/claude_live_turn.mjs +105 -38
- package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.mjs +5 -3
- package/assets/slopmachine/utils/claude_worker_common.mjs +18 -1
- package/assets/slopmachine/utils/package_claude_session.mjs +40 -268
- package/package.json +1 -1
- package/src/constants.js +0 -1
- package/src/install.js +2 -1
- package/src/send-data.js +0 -4
- package/assets/slopmachine/utils/claude_live_channel.mjs +0 -188
|
@@ -134,18 +134,20 @@ Metadata for a Claude lane should include:
|
|
|
134
134
|
|
|
135
135
|
- Rate-limit handling must be uninterrupted and script-managed. Use the live helper scripts only; never type manually into the tmux pane, send an ad hoc shell command to Claude, switch lanes, or ask the user whether to wait.
|
|
136
136
|
- The live helpers read the tmux pane when a rate-limit/usage-limit prompt appears.
|
|
137
|
-
- If Claude shows a wait/continue prompt
|
|
137
|
+
- If Claude shows a wait/continue prompt such as `Stop and wait for limit to reset`, the helper presses Enter to select the wait/continue path.
|
|
138
138
|
- The helper records blocked and dismissed pane snapshots under the lane runtime directory.
|
|
139
|
-
- The helper calculates the reset time from structured metadata
|
|
139
|
+
- The helper calculates the reset time from structured metadata, hook payload text, `last_assistant_message`, and tmux pane text, falls back to the default quota reset window only when those sources do not contain a usable reset time, records the timer fields in `state.json`, waits, then continues the same turn in the same Claude lane.
|
|
140
|
+
- If a shell command is interrupted during a wait, rerun the same turn/status helper. It must reuse the blocked turn state and continue waiting/recovering from the recorded reset time instead of starting a duplicate prompt.
|
|
140
141
|
- The continuation prompt must tell Claude to continue from the interruption point, not restart from scratch.
|
|
141
142
|
- Shell timeouts during a rate-limit wait do not mean the turn failed. Run `claude_live_status.mjs`, let it reconcile or keep waiting on the same lane, then continue the same workflow step until a terminal result exists.
|
|
142
143
|
- Do not report the workflow as blocked or finished because of a rate limit unless the helper scripts themselves hit an unrecoverable technical failure that cannot be repaired without user action.
|
|
143
144
|
|
|
144
|
-
##
|
|
145
|
+
## Tmux Prompt Injection Recovery
|
|
145
146
|
|
|
146
|
-
- Launch must verify
|
|
147
|
-
-
|
|
148
|
-
-
|
|
147
|
+
- Launch must verify `SessionStart` and a captured `sid` before a lane is usable.
|
|
148
|
+
- Turns are sent by writing the prompt file, loading it into a tmux paste buffer, pasting it into Claude's interactive input, and pressing Enter. Hook events remain the response/completion source of truth.
|
|
149
|
+
- Before pasting, the helper must inspect the pane and handle known startup, confirmation, busy, and rate-limit popups heuristically. Do not paste into a pane while a popup or busy state is detected.
|
|
150
|
+
- If an existing lane has a live tmux session but no usable `SessionStart`/`sid`, relaunch with `--resume`/existing `sid` continuity when available.
|
|
149
151
|
- If ownership cannot be proven, stop and ask the user rather than killing or replacing the session.
|
|
150
152
|
|
|
151
153
|
## Session Continuity Guarantees
|
|
@@ -22,7 +22,7 @@ The current type-check gate covers the Claude/session tooling family because the
|
|
|
22
22
|
|
|
23
23
|
### `claude_live_launch.mjs`
|
|
24
24
|
|
|
25
|
-
Launches a live Claude Code session in tmux with
|
|
25
|
+
Launches a live Claude Code session in tmux with hook-based result capture.
|
|
26
26
|
|
|
27
27
|
Required:
|
|
28
28
|
- `--task-root <task-root>`
|
|
@@ -40,7 +40,7 @@ Output is JSON. Success includes `ok: true`, `sid`, `state_file`, and `result_fi
|
|
|
40
40
|
|
|
41
41
|
### `claude_live_turn.mjs`
|
|
42
42
|
|
|
43
|
-
Sends one prompt into an existing live Claude lane
|
|
43
|
+
Sends one prompt into an existing live Claude lane by loading the prompt file into a tmux paste buffer, pasting it into Claude's interactive input, pressing Enter, and waiting for hook completion.
|
|
44
44
|
|
|
45
45
|
Required:
|
|
46
46
|
- `--runtime-dir <dir>`
|
|
@@ -58,7 +58,7 @@ Reads the live-lane runtime state.
|
|
|
58
58
|
Required:
|
|
59
59
|
- `--runtime-dir <dir>`
|
|
60
60
|
|
|
61
|
-
Output is JSON with lane status, cwd, sid, runtime files, transcript path,
|
|
61
|
+
Output is JSON with lane status, cwd, sid, runtime files, transcript path, tmux state, and last error.
|
|
62
62
|
|
|
63
63
|
### `claude_live_stop.mjs`
|
|
64
64
|
|
|
@@ -69,16 +69,6 @@ Required:
|
|
|
69
69
|
|
|
70
70
|
The helper only kills tmux when bridge state proves ownership. If ownership is ambiguous, it leaves tmux alone and reports the reason.
|
|
71
71
|
|
|
72
|
-
### `claude_live_channel.mjs`
|
|
73
|
-
|
|
74
|
-
Internal JSON-RPC/HTTP bridge used by live lanes. It accepts Claude initialization over stdio and POSTs from `claude_live_turn.mjs`, then emits `notifications/claude/channel` to Claude.
|
|
75
|
-
|
|
76
|
-
Required:
|
|
77
|
-
- `--port <port>`
|
|
78
|
-
- `--token <secret>`
|
|
79
|
-
- `--state-file <file>`
|
|
80
|
-
- `--log-file <file>`
|
|
81
|
-
|
|
82
72
|
### `claude_live_hook.py`
|
|
83
73
|
|
|
84
74
|
Claude Code hook used by live lanes to append hook events under the runtime directory.
|
|
@@ -123,14 +113,12 @@ Packages the Claude project directory associated with a task root.
|
|
|
123
113
|
|
|
124
114
|
Required:
|
|
125
115
|
- `--task-root <task-root>`
|
|
126
|
-
- `--output <zip-path>`
|
|
127
116
|
|
|
128
117
|
Important options:
|
|
129
|
-
- `--
|
|
130
|
-
- `--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`
|
|
131
119
|
- `--label <text>` adds reporting context
|
|
132
120
|
|
|
133
|
-
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.
|
|
134
122
|
|
|
135
123
|
### `analyze_claude_project_dir.mjs`
|
|
136
124
|
|
|
@@ -194,10 +182,6 @@ Common usage:
|
|
|
194
182
|
- `python3 convert_ai_session.py -i session.jsonl -o converted.json`
|
|
195
183
|
- `python3 convert_ai_session.py -i session.jsonl --format claude`
|
|
196
184
|
|
|
197
|
-
### `normalize_claude_session.py`
|
|
198
|
-
|
|
199
|
-
Normalizes Claude JSONL transcript structure for packaging and review.
|
|
200
|
-
|
|
201
185
|
### `strip_session_parent.py`
|
|
202
186
|
|
|
203
187
|
Removes parent/session ancestry fields from exported session artifacts when needed for sanitized review.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs/promises'
|
|
4
|
-
import http from 'node:http'
|
|
5
4
|
import net from 'node:net'
|
|
6
5
|
import path from 'node:path'
|
|
7
6
|
import crypto from 'node:crypto'
|
|
@@ -14,7 +13,6 @@ export { emitFailure, emitSuccess, parseArgs, printUsageAndExit, readPrompt, rea
|
|
|
14
13
|
|
|
15
14
|
export const DEFAULT_LAUNCH_TIMEOUT_MS = 3600000
|
|
16
15
|
export const DEFAULT_TURN_TIMEOUT_MS = 3600000
|
|
17
|
-
export const DEFAULT_CHANNEL_POST_TIMEOUT_MS = 10000
|
|
18
16
|
export const DEFAULT_POLL_INTERVAL_MS = 500
|
|
19
17
|
export const ROOT_PHASE_TITLES = [
|
|
20
18
|
'P1 Clarification',
|
|
@@ -35,9 +33,6 @@ export function buildRuntimePaths(runtimeDir) {
|
|
|
35
33
|
stateFile: path.join(absoluteRuntimeDir, 'state.json'),
|
|
36
34
|
resultFile: path.join(absoluteRuntimeDir, 'result.json'),
|
|
37
35
|
settingsFile: path.join(absoluteRuntimeDir, 'settings.json'),
|
|
38
|
-
mcpConfigFile: path.join(absoluteRuntimeDir, 'mcp.json'),
|
|
39
|
-
channelStateFile: path.join(absoluteRuntimeDir, 'channel-state.json'),
|
|
40
|
-
channelLogFile: path.join(absoluteRuntimeDir, 'channel.log'),
|
|
41
36
|
hookEventsFile: path.join(absoluteRuntimeDir, 'hook-events.jsonl'),
|
|
42
37
|
turnsDir: path.join(absoluteRuntimeDir, 'turns'),
|
|
43
38
|
}
|
|
@@ -81,8 +76,6 @@ export async function writeState(runtimeDir, patch) {
|
|
|
81
76
|
|
|
82
77
|
export async function clearRuntimeArtifacts(paths) {
|
|
83
78
|
await writeFileIfNeeded(paths.hookEventsFile, '')
|
|
84
|
-
await writeFileIfNeeded(paths.channelLogFile, '')
|
|
85
|
-
await fs.rm(paths.channelStateFile, { force: true })
|
|
86
79
|
await fs.rm(paths.resultFile, { force: true })
|
|
87
80
|
}
|
|
88
81
|
|
|
@@ -324,21 +317,23 @@ export async function captureTmuxPaneArtifact(sessionName, filePath) {
|
|
|
324
317
|
return pane
|
|
325
318
|
}
|
|
326
319
|
|
|
327
|
-
export function
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
320
|
+
export async function tmuxSendEnter(sessionName) {
|
|
321
|
+
return runCommand('tmux', ['send-keys', '-t', sessionName, 'Enter'])
|
|
322
|
+
}
|
|
331
323
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
324
|
+
export async function tmuxPasteFileAndEnter(sessionName, filePath, bufferName) {
|
|
325
|
+
const loadResult = await runCommand('tmux', ['load-buffer', '-b', bufferName, filePath])
|
|
326
|
+
if (loadResult.code !== 0) {
|
|
327
|
+
return loadResult
|
|
335
328
|
}
|
|
336
329
|
|
|
337
|
-
|
|
338
|
-
|
|
330
|
+
const pasteResult = await runCommand('tmux', ['paste-buffer', '-t', sessionName, '-b', bufferName])
|
|
331
|
+
await runCommand('tmux', ['delete-buffer', '-b', bufferName])
|
|
332
|
+
if (pasteResult.code !== 0) {
|
|
333
|
+
return pasteResult
|
|
334
|
+
}
|
|
339
335
|
|
|
340
|
-
|
|
341
|
-
return runCommand('tmux', ['send-keys', '-t', sessionName, 'Enter'])
|
|
336
|
+
return tmuxSendEnter(sessionName)
|
|
342
337
|
}
|
|
343
338
|
|
|
344
339
|
function detectClaudeRateLimitPrompt(pane) {
|
|
@@ -348,17 +343,20 @@ function detectClaudeRateLimitPrompt(pane) {
|
|
|
348
343
|
|
|
349
344
|
const normalized = pane.toLowerCase()
|
|
350
345
|
const hasRateLimitSignal = /hit your limit|usage limit|rate limit|capacity|overloaded|try again/i.test(normalized)
|
|
346
|
+
const hasResetTimeSignal = /\breset(?:s| time)?\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?(?:\s*\([^)]+\))?/i.test(pane)
|
|
351
347
|
const hasContinueSignal = /press\s+(enter|return)|hit\s+(enter|return)|enter\s+to\s+continue|return\s+to\s+continue|enter\s+to\s+confirm|return\s+to\s+confirm/i.test(normalized)
|
|
348
|
+
const hasSelectedWaitOption = /(?:❯|>|➜|→)?\s*\d+\.\s*stop and wait for limit to reset/i.test(pane)
|
|
349
|
+
const hasPlainWaitOption = /stop and wait for limit to reset/i.test(normalized)
|
|
352
350
|
const hasWaitMenuSignal = normalized.includes('what do you want to do?')
|
|
353
351
|
&& normalized.includes('stop and wait for limit to reset')
|
|
354
352
|
const hasWaitMenuOptions = normalized.includes('request more')
|
|
355
353
|
&& /enter\s+to\s+confirm|return\s+to\s+confirm/.test(normalized)
|
|
356
354
|
|
|
357
|
-
if (hasWaitMenuSignal || hasWaitMenuOptions) {
|
|
355
|
+
if (hasSelectedWaitOption || hasWaitMenuSignal || hasWaitMenuOptions) {
|
|
358
356
|
return true
|
|
359
357
|
}
|
|
360
358
|
|
|
361
|
-
return hasRateLimitSignal && (hasContinueSignal ||
|
|
359
|
+
return hasRateLimitSignal && (hasContinueSignal || hasPlainWaitOption || hasResetTimeSignal)
|
|
362
360
|
}
|
|
363
361
|
|
|
364
362
|
export async function maybeDismissClaudeRateLimitPrompt(sessionName, timeoutMs = 15000) {
|
|
@@ -440,13 +438,6 @@ function detectClaudeStartupPrompt(pane) {
|
|
|
440
438
|
return 'workspace-trust'
|
|
441
439
|
}
|
|
442
440
|
|
|
443
|
-
if (
|
|
444
|
-
pane.includes('WARNING: Loading development channels')
|
|
445
|
-
|| pane.includes('I am using this for local development')
|
|
446
|
-
) {
|
|
447
|
-
return 'development-channels'
|
|
448
|
-
}
|
|
449
|
-
|
|
450
441
|
return null
|
|
451
442
|
}
|
|
452
443
|
|
|
@@ -457,10 +448,6 @@ export async function maybeAcceptClaudeStartupPrompts(sessionName, timeoutMs = 3
|
|
|
457
448
|
|
|
458
449
|
while (Date.now() < deadline) {
|
|
459
450
|
const pane = await tmuxCapturePane(sessionName)
|
|
460
|
-
if (pane.includes('Listening for channel messages from:')) {
|
|
461
|
-
return false
|
|
462
|
-
}
|
|
463
|
-
|
|
464
451
|
const promptKind = detectClaudeStartupPrompt(pane)
|
|
465
452
|
if (promptKind) {
|
|
466
453
|
const now = Date.now()
|
|
@@ -482,17 +469,64 @@ export async function maybeAcceptClaudeStartupPrompts(sessionName, timeoutMs = 3
|
|
|
482
469
|
return false
|
|
483
470
|
}
|
|
484
471
|
|
|
485
|
-
export
|
|
486
|
-
|
|
487
|
-
const channelState = await readJsonIfExists(paths.channelStateFile)
|
|
488
|
-
if (channelState?.ready_for_notifications) {
|
|
489
|
-
return channelState
|
|
490
|
-
}
|
|
472
|
+
export function detectClaudePopupOrBlockedPane(pane) {
|
|
473
|
+
if (!pane) {
|
|
491
474
|
return null
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const startupPrompt = detectClaudeStartupPrompt(pane)
|
|
478
|
+
if (startupPrompt) {
|
|
479
|
+
return startupPrompt
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (detectClaudeRateLimitPrompt(pane)) {
|
|
483
|
+
return 'rate-limit'
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const normalized = pane.toLowerCase()
|
|
487
|
+
if (/press\s+(enter|return)|hit\s+(enter|return)|enter\s+to\s+continue|return\s+to\s+continue|enter\s+to\s+confirm|return\s+to\s+confirm/.test(normalized)) {
|
|
488
|
+
return 'enter-confirmation'
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (/\b(yes|no)\b.*\b(continue|proceed|trust|allow)|\b(continue|proceed|trust|allow)\b.*\b(yes|no)\b/.test(normalized)) {
|
|
492
|
+
return 'confirmation-menu'
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (/esc to interrupt|ctrl-c to cancel|interrupt/i.test(pane)) {
|
|
496
|
+
return 'busy'
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return null
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export async function prepareClaudePaneForPrompt(sessionName, { statePath = null, timeoutMs = 30000 } = {}) {
|
|
503
|
+
const deadline = Date.now() + timeoutMs
|
|
504
|
+
let lastPane = ''
|
|
505
|
+
let lastBlocker = null
|
|
506
|
+
|
|
507
|
+
while (Date.now() < deadline) {
|
|
508
|
+
await maybeAcceptClaudeStartupPrompts(sessionName, 1000)
|
|
509
|
+
const pane = await tmuxCapturePane(sessionName)
|
|
510
|
+
lastPane = pane
|
|
511
|
+
lastBlocker = detectClaudePopupOrBlockedPane(pane)
|
|
512
|
+
|
|
513
|
+
if (lastBlocker === 'rate-limit') {
|
|
514
|
+
const popupDismissed = await maybeDismissClaudeRateLimitPrompt(sessionName, 5000)
|
|
515
|
+
const waitInfo = await waitForRateLimitReset({
|
|
516
|
+
message: pane,
|
|
517
|
+
statePath,
|
|
518
|
+
})
|
|
519
|
+
return { ready: false, blocker: 'rate-limit', popup_dismissed: popupDismissed, wait_info: waitInfo }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!lastBlocker) {
|
|
523
|
+
return { ready: true, blocker: null, pane }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
await sleep(DEFAULT_POLL_INTERVAL_MS)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return { ready: false, blocker: lastBlocker || 'unknown', pane: lastPane }
|
|
496
530
|
}
|
|
497
531
|
|
|
498
532
|
export async function readJsonl(filePath) {
|
|
@@ -550,32 +584,7 @@ export function buildHookSettings({ runtimeDir, utilsDir, agentName = 'developer
|
|
|
550
584
|
return settings
|
|
551
585
|
}
|
|
552
586
|
|
|
553
|
-
export function
|
|
554
|
-
return {
|
|
555
|
-
mcpServers: {
|
|
556
|
-
[channelName]: {
|
|
557
|
-
command: 'node',
|
|
558
|
-
args: [
|
|
559
|
-
path.join(utilsDir, 'claude_live_channel.mjs'),
|
|
560
|
-
'--port',
|
|
561
|
-
String(port),
|
|
562
|
-
'--token',
|
|
563
|
-
token,
|
|
564
|
-
'--state-file',
|
|
565
|
-
paths.channelStateFile,
|
|
566
|
-
'--log-file',
|
|
567
|
-
paths.channelLogFile,
|
|
568
|
-
'--channel-name',
|
|
569
|
-
channelName,
|
|
570
|
-
'--lane',
|
|
571
|
-
lane,
|
|
572
|
-
],
|
|
573
|
-
},
|
|
574
|
-
},
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
export function buildClaudeLaunchCommand({ claudeCommand, agentName, displayName, settingsFile, mcpConfigFile, channelName, model, effort = null, subagentModel = 'sonnet', supportsSubagentModel = false, resumeSessionId = null }) {
|
|
587
|
+
export function buildClaudeLaunchCommand({ claudeCommand, agentName, settingsFile, model, effort = null, subagentModel = 'sonnet', supportsSubagentModel = false, resumeSessionId = null }) {
|
|
579
588
|
const parts = []
|
|
580
589
|
|
|
581
590
|
if (subagentModel) {
|
|
@@ -586,15 +595,9 @@ export function buildClaudeLaunchCommand({ claudeCommand, agentName, displayName
|
|
|
586
595
|
shellQuote(claudeCommand),
|
|
587
596
|
'--agent',
|
|
588
597
|
shellQuote(agentName),
|
|
589
|
-
'-n',
|
|
590
|
-
shellQuote(displayName),
|
|
591
598
|
'--settings',
|
|
592
599
|
shellQuote(settingsFile),
|
|
593
|
-
'--mcp-config',
|
|
594
|
-
shellQuote(mcpConfigFile),
|
|
595
600
|
'--dangerously-skip-permissions',
|
|
596
|
-
'--dangerously-load-development-channels',
|
|
597
|
-
shellQuote(`server:${channelName}`),
|
|
598
601
|
)
|
|
599
602
|
|
|
600
603
|
if (resumeSessionId) {
|
|
@@ -616,43 +619,6 @@ export function buildClaudeLaunchCommand({ claudeCommand, agentName, displayName
|
|
|
616
619
|
return parts.join(' ')
|
|
617
620
|
}
|
|
618
621
|
|
|
619
|
-
export async function postPromptToChannel({ state, prompt, turnId, timeoutMs = DEFAULT_CHANNEL_POST_TIMEOUT_MS }) {
|
|
620
|
-
return new Promise((resolve, reject) => {
|
|
621
|
-
const request = http.request({
|
|
622
|
-
hostname: '127.0.0.1',
|
|
623
|
-
port: state.channel_port,
|
|
624
|
-
path: '/',
|
|
625
|
-
method: 'POST',
|
|
626
|
-
headers: {
|
|
627
|
-
'content-type': 'text/plain; charset=utf-8',
|
|
628
|
-
'content-length': Buffer.byteLength(prompt, 'utf8'),
|
|
629
|
-
'x-bridge-token': state.channel_token,
|
|
630
|
-
'x-lane': state.lane,
|
|
631
|
-
'x-turn-id': turnId,
|
|
632
|
-
},
|
|
633
|
-
timeout: timeoutMs,
|
|
634
|
-
}, (response) => {
|
|
635
|
-
let body = ''
|
|
636
|
-
response.on('data', (chunk) => {
|
|
637
|
-
body += chunk.toString()
|
|
638
|
-
})
|
|
639
|
-
response.on('end', () => {
|
|
640
|
-
resolve({
|
|
641
|
-
statusCode: response.statusCode || 0,
|
|
642
|
-
body,
|
|
643
|
-
})
|
|
644
|
-
})
|
|
645
|
-
})
|
|
646
|
-
|
|
647
|
-
request.on('error', reject)
|
|
648
|
-
request.on('timeout', () => {
|
|
649
|
-
request.destroy(new Error('channel_post_timeout'))
|
|
650
|
-
})
|
|
651
|
-
request.write(prompt)
|
|
652
|
-
request.end()
|
|
653
|
-
})
|
|
654
|
-
}
|
|
655
|
-
|
|
656
622
|
export function buildTurnId(lastTurnNumber) {
|
|
657
623
|
return String(lastTurnNumber + 1).padStart(4, '0')
|
|
658
624
|
}
|
|
@@ -673,6 +639,7 @@ export function extractFailureMessage(payload) {
|
|
|
673
639
|
if (!payload) return ''
|
|
674
640
|
|
|
675
641
|
const candidates = [
|
|
642
|
+
payload.last_assistant_message,
|
|
676
643
|
payload.message,
|
|
677
644
|
payload.error,
|
|
678
645
|
payload.failure_reason,
|
|
@@ -698,7 +665,7 @@ export function classifyStopFailure(event, fallbackSid = null) {
|
|
|
698
665
|
const message = extractFailureMessage(payload) || 'claude_stop_failure'
|
|
699
666
|
const rateLimit = extractRateLimitMetadata(payload)
|
|
700
667
|
|
|
701
|
-
if (/hit your limit|usage limit|capacity|overloaded/i.test(message)) {
|
|
668
|
+
if (/hit your limit|usage limit|rate[_ -]?limit|capacity|overloaded/i.test(message)) {
|
|
702
669
|
return {
|
|
703
670
|
result: { ok: false, code: 'claude_usage_limit', msg: 'usage_limit', detail: message, rate_limit: rateLimit, sid },
|
|
704
671
|
nextStatus: 'blocked',
|
|
@@ -4,21 +4,17 @@ import path from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_LAUNCH_TIMEOUT_MS,
|
|
7
|
-
allocatePort,
|
|
8
7
|
assessOwnedTmuxSession,
|
|
9
8
|
buildClaudeLaunchCommand,
|
|
10
9
|
buildHookSettings,
|
|
11
|
-
buildMcpConfig,
|
|
12
10
|
buildRuntimePaths,
|
|
13
11
|
captureTmuxPaneArtifact,
|
|
14
12
|
clearRuntimeArtifacts,
|
|
15
|
-
detectClaudeDevelopmentChannelDisabled,
|
|
16
13
|
detectClaudeCliCapabilities,
|
|
17
14
|
emitFailure,
|
|
18
15
|
emitSuccess,
|
|
19
16
|
ensureRuntimeDirs,
|
|
20
17
|
makeSuffix,
|
|
21
|
-
makeToken,
|
|
22
18
|
maybeAcceptClaudeStartupPrompts,
|
|
23
19
|
maybeDismissClaudeRateLimitPrompt,
|
|
24
20
|
parseArgs,
|
|
@@ -32,7 +28,6 @@ import {
|
|
|
32
28
|
tmuxKillSession,
|
|
33
29
|
tmuxCapturePane,
|
|
34
30
|
waitForRateLimitReset,
|
|
35
|
-
waitForChannelReady,
|
|
36
31
|
waitForHookEvent,
|
|
37
32
|
writeJsonIfNeeded,
|
|
38
33
|
writeState,
|
|
@@ -76,23 +71,20 @@ function detectRateLimitFromPane(pane) {
|
|
|
76
71
|
|
|
77
72
|
function detectStartupPromptFromPane(pane) {
|
|
78
73
|
if (!pane) return false
|
|
79
|
-
return /quick safety check:|yes, i trust this folder|accessing workspace
|
|
74
|
+
return /quick safety check:|yes, i trust this folder|accessing workspace:/i.test(pane)
|
|
80
75
|
}
|
|
81
76
|
|
|
82
77
|
async function inspectLaunchState(paths, tmuxSession) {
|
|
83
|
-
const [tmuxAlive,
|
|
78
|
+
const [tmuxAlive, events, pane] = await Promise.all([
|
|
84
79
|
tmuxHasSession(tmuxSession),
|
|
85
|
-
readJsonIfExists(paths.channelStateFile),
|
|
86
80
|
readJsonl(paths.hookEventsFile),
|
|
87
81
|
tmuxCapturePane(tmuxSession),
|
|
88
82
|
])
|
|
89
83
|
|
|
90
84
|
const sessionStart = events.find((event) => event?.label === 'SessionStart') || null
|
|
91
85
|
const sessionId = sessionStart?.payload?.session_id || null
|
|
92
|
-
const channelReady = Boolean(channelState?.ready_for_notifications)
|
|
93
86
|
const rateLimited = detectRateLimitFromPane(pane)
|
|
94
87
|
const startupPromptVisible = detectStartupPromptFromPane(pane)
|
|
95
|
-
const developmentChannelDisabled = detectClaudeDevelopmentChannelDisabled(pane)
|
|
96
88
|
|
|
97
89
|
let code = 'claude_launch_not_ready'
|
|
98
90
|
let message = 'Claude launch did not reach a ready session state yet.'
|
|
@@ -102,16 +94,7 @@ async function inspectLaunchState(paths, tmuxSession) {
|
|
|
102
94
|
} else if (rateLimited) {
|
|
103
95
|
code = 'claude_launch_rate_limited'
|
|
104
96
|
message = 'Claude launch is blocked by a rate-limit or capacity prompt.'
|
|
105
|
-
} else if (
|
|
106
|
-
code = 'claude_launch_development_channels_disabled'
|
|
107
|
-
message = 'Claude launch appears to have development channels disabled; this lane must be restarted before any prompt is sent.'
|
|
108
|
-
} else if (sessionStart && !channelReady) {
|
|
109
|
-
code = 'claude_launch_no_channel_ready'
|
|
110
|
-
message = 'Claude session started but the channel never became ready.'
|
|
111
|
-
} else if (!sessionStart && channelReady) {
|
|
112
|
-
code = 'claude_launch_no_sessionstart'
|
|
113
|
-
message = 'Claude channel became ready but the SessionStart hook event never arrived.'
|
|
114
|
-
} else if (sessionStart && channelReady && !sessionId) {
|
|
97
|
+
} else if (sessionStart && !sessionId) {
|
|
115
98
|
code = 'claude_launch_missing_session_id'
|
|
116
99
|
message = 'Claude launch reached partial readiness but no session id was captured.'
|
|
117
100
|
} else if (startupPromptVisible) {
|
|
@@ -121,12 +104,10 @@ async function inspectLaunchState(paths, tmuxSession) {
|
|
|
121
104
|
|
|
122
105
|
return {
|
|
123
106
|
tmuxAlive,
|
|
124
|
-
channelReady,
|
|
125
107
|
sessionStart,
|
|
126
108
|
sessionId,
|
|
127
109
|
rateLimited,
|
|
128
110
|
startupPromptVisible,
|
|
129
|
-
developmentChannelDisabled,
|
|
130
111
|
code,
|
|
131
112
|
message,
|
|
132
113
|
}
|
|
@@ -135,8 +116,6 @@ async function inspectLaunchState(paths, tmuxSession) {
|
|
|
135
116
|
function shouldRetryFreshTmuxLaunch(error) {
|
|
136
117
|
const code = error && typeof error === 'object' ? error.code : null
|
|
137
118
|
return [
|
|
138
|
-
'claude_launch_development_channels_disabled',
|
|
139
|
-
'claude_launch_no_channel_ready',
|
|
140
119
|
'claude_launch_no_sessionstart',
|
|
141
120
|
'claude_launch_not_ready',
|
|
142
121
|
'claude_launch_prompt_blocked',
|
|
@@ -156,19 +135,21 @@ async function waitForLaunchReadyWithRecovery({ paths, tmuxSession, launchTimeou
|
|
|
156
135
|
maybeDismissClaudeRateLimitPrompt(tmuxSession, Math.min(windowMs, 5000)),
|
|
157
136
|
])
|
|
158
137
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
138
|
+
let hookReady = null
|
|
139
|
+
try {
|
|
140
|
+
hookReady = await waitForHookEvent(
|
|
141
|
+
paths,
|
|
142
|
+
0,
|
|
143
|
+
new Set(['SessionStart']),
|
|
144
|
+
windowMs,
|
|
145
|
+
)
|
|
146
|
+
} catch {}
|
|
163
147
|
|
|
164
|
-
const hookReady = hookResult.status === 'fulfilled' ? hookResult.value : null
|
|
165
|
-
const channelReady = channelResult.status === 'fulfilled' ? channelResult.value : null
|
|
166
148
|
const sessionId = hookReady?.event?.payload?.session_id || null
|
|
167
149
|
|
|
168
|
-
if (hookReady &&
|
|
150
|
+
if (hookReady && sessionId) {
|
|
169
151
|
return {
|
|
170
152
|
event: hookReady.event,
|
|
171
|
-
channelState: channelReady,
|
|
172
153
|
}
|
|
173
154
|
}
|
|
174
155
|
|
|
@@ -234,7 +215,6 @@ Options:
|
|
|
234
215
|
--resume-sid <sid> Resume an explicit Claude session id
|
|
235
216
|
--replace 1 Replace an existing live lane state/session
|
|
236
217
|
--tmux-session <name> Override tmux session name
|
|
237
|
-
--channel-name <name> Override bridge channel name
|
|
238
218
|
`)
|
|
239
219
|
}
|
|
240
220
|
|
|
@@ -269,10 +249,8 @@ try {
|
|
|
269
249
|
if (existingState?.tmux_session && await tmuxHasSession(existingState.tmux_session)) {
|
|
270
250
|
if (!replace) {
|
|
271
251
|
const existingInspection = await inspectLaunchState(paths, existingState.tmux_session)
|
|
272
|
-
if (existingInspection.
|
|
273
|
-
const code =
|
|
274
|
-
? 'claude_existing_development_channels_disabled'
|
|
275
|
-
: 'claude_existing_channel_not_ready'
|
|
252
|
+
if (!existingInspection.sessionStart || !existingInspection.sessionId) {
|
|
253
|
+
const code = 'claude_existing_not_ready'
|
|
276
254
|
const ownership = await assessOwnedTmuxSession({ runtimeDir, state: existingState, cwd })
|
|
277
255
|
if (!ownership.ok) {
|
|
278
256
|
emitFailure(code, `${existingInspection.message} Refusing automatic restart because tmux ownership is not proven: ${ownership.reason}`, {
|
|
@@ -353,15 +331,10 @@ try {
|
|
|
353
331
|
const cliCapabilities = await detectClaudeCliCapabilities(claudeCommand)
|
|
354
332
|
|
|
355
333
|
async function launchAttempt(attemptNumber, attemptsTotal) {
|
|
356
|
-
const suffix =
|
|
334
|
+
const suffix = makeSuffix()
|
|
357
335
|
const tmuxSession = argv['tmux-session'] && attemptNumber === 1
|
|
358
336
|
? argv['tmux-session']
|
|
359
337
|
: `sm-${sanitizeName(lane)}-${suffix}`
|
|
360
|
-
const channelName = argv['channel-name'] && attemptNumber === 1
|
|
361
|
-
? argv['channel-name']
|
|
362
|
-
: `slopmachine-${sanitizeName(lane)}-${suffix}`
|
|
363
|
-
const channelPort = await allocatePort()
|
|
364
|
-
const channelToken = makeToken()
|
|
365
338
|
|
|
366
339
|
await clearRuntimeArtifacts(paths)
|
|
367
340
|
await writeState(runtimeDir, {
|
|
@@ -376,14 +349,9 @@ try {
|
|
|
376
349
|
effort: laneEffort,
|
|
377
350
|
subagent_model: subagentModel,
|
|
378
351
|
tmux_session: tmuxSession,
|
|
379
|
-
channel_name: channelName,
|
|
380
|
-
channel_port: channelPort,
|
|
381
|
-
channel_token: channelToken,
|
|
382
352
|
runtime_dir: paths.runtimeDir,
|
|
383
353
|
settings_file: paths.settingsFile,
|
|
384
|
-
mcp_config_file: paths.mcpConfigFile,
|
|
385
354
|
hook_events_file: paths.hookEventsFile,
|
|
386
|
-
channel_state_file: paths.channelStateFile,
|
|
387
355
|
result_file: paths.resultFile,
|
|
388
356
|
transcript_path: null,
|
|
389
357
|
current_turn_id: null,
|
|
@@ -396,30 +364,17 @@ try {
|
|
|
396
364
|
started_at: new Date().toISOString(),
|
|
397
365
|
})
|
|
398
366
|
|
|
399
|
-
await
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
})),
|
|
406
|
-
writeJsonIfNeeded(paths.mcpConfigFile, buildMcpConfig({
|
|
407
|
-
paths,
|
|
408
|
-
utilsDir,
|
|
409
|
-
channelName,
|
|
410
|
-
lane,
|
|
411
|
-
port: channelPort,
|
|
412
|
-
token: channelToken,
|
|
413
|
-
})),
|
|
414
|
-
])
|
|
367
|
+
await writeJsonIfNeeded(paths.settingsFile, buildHookSettings({
|
|
368
|
+
runtimeDir: paths.runtimeDir,
|
|
369
|
+
utilsDir,
|
|
370
|
+
agentName,
|
|
371
|
+
subagentModel,
|
|
372
|
+
}))
|
|
415
373
|
|
|
416
374
|
const launchCommand = buildClaudeLaunchCommand({
|
|
417
375
|
claudeCommand,
|
|
418
376
|
agentName,
|
|
419
|
-
displayName: lane,
|
|
420
377
|
settingsFile: paths.settingsFile,
|
|
421
|
-
mcpConfigFile: paths.mcpConfigFile,
|
|
422
|
-
channelName,
|
|
423
378
|
model: laneModel,
|
|
424
379
|
effort: laneEffort,
|
|
425
380
|
subagentModel,
|
|
@@ -440,7 +395,7 @@ try {
|
|
|
440
395
|
|
|
441
396
|
emitTmuxLaunchHint({ tmuxSession, cwd })
|
|
442
397
|
|
|
443
|
-
const { event
|
|
398
|
+
const { event } = await waitForLaunchReadyWithRecovery({
|
|
444
399
|
paths,
|
|
445
400
|
tmuxSession,
|
|
446
401
|
launchTimeoutMs,
|
|
@@ -455,7 +410,6 @@ try {
|
|
|
455
410
|
transcript_path: transcriptPath,
|
|
456
411
|
last_hook_event: 'SessionStart',
|
|
457
412
|
last_error: null,
|
|
458
|
-
channel_status: channelState.status || 'ready',
|
|
459
413
|
launch_attempt: attemptNumber,
|
|
460
414
|
launch_attempts_total: attemptsTotal,
|
|
461
415
|
})
|