theslopmachine 1.0.6 → 1.0.7
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 +3 -13
- 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/package.json +1 -1
- package/src/constants.js +0 -1
- package/src/install.js +2 -1
- 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.
|
|
@@ -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
|
})
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
classifyStopFailure,
|
|
8
8
|
parseArgs,
|
|
9
9
|
printUsageAndExit,
|
|
10
|
-
readJsonIfExists,
|
|
11
10
|
readJsonl,
|
|
12
11
|
readPrompt,
|
|
13
12
|
readState,
|
|
@@ -36,9 +35,8 @@ if (!state) {
|
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
const paths = buildRuntimePaths(runtimeDir)
|
|
39
|
-
let [tmuxAlive,
|
|
38
|
+
let [tmuxAlive, hookEvents] = await Promise.all([
|
|
40
39
|
tmuxHasSession(state.tmux_session),
|
|
41
|
-
readJsonIfExists(paths.channelStateFile),
|
|
42
40
|
readJsonl(paths.hookEventsFile),
|
|
43
41
|
])
|
|
44
42
|
|
|
@@ -116,15 +114,13 @@ async function reconcileInterruptedTurn() {
|
|
|
116
114
|
|
|
117
115
|
if (await reconcileInterruptedTurn()) {
|
|
118
116
|
state = await readState(runtimeDir)
|
|
119
|
-
;[tmuxAlive,
|
|
117
|
+
;[tmuxAlive, hookEvents] = await Promise.all([
|
|
120
118
|
tmuxHasSession(state.tmux_session),
|
|
121
|
-
readJsonIfExists(paths.channelStateFile),
|
|
122
119
|
readJsonl(paths.hookEventsFile),
|
|
123
120
|
])
|
|
124
121
|
}
|
|
125
122
|
|
|
126
123
|
const sessionStart = hookEvents.find((event) => event?.label === 'SessionStart') || null
|
|
127
|
-
const channelReady = Boolean(channelState?.ready_for_notifications)
|
|
128
124
|
|
|
129
125
|
function deriveHealthCode() {
|
|
130
126
|
if (state.status === 'stopped') return 'stopped'
|
|
@@ -151,12 +147,10 @@ function deriveHealthCode() {
|
|
|
151
147
|
}
|
|
152
148
|
|
|
153
149
|
if (state.status === 'starting') {
|
|
154
|
-
if (sessionStart &&
|
|
150
|
+
if (sessionStart && state.sid) return 'ready'
|
|
155
151
|
if (state.launch_recovery_code === 'claude_launch_rate_limited') return 'startup_rate_limited'
|
|
156
152
|
if (state.launch_recovery_code === 'claude_launch_prompt_blocked') return 'startup_prompt_blocked'
|
|
157
|
-
if (sessionStart &&
|
|
158
|
-
if (sessionStart && !channelReady) return 'partial_ready_missing_channel'
|
|
159
|
-
if (!sessionStart && channelReady) return 'partial_ready_missing_sessionstart'
|
|
153
|
+
if (sessionStart && !state.sid) return 'partial_ready_missing_session_id'
|
|
160
154
|
return 'starting'
|
|
161
155
|
}
|
|
162
156
|
|
|
@@ -164,13 +158,10 @@ function deriveHealthCode() {
|
|
|
164
158
|
}
|
|
165
159
|
|
|
166
160
|
const healthCode = deriveHealthCode()
|
|
167
|
-
const { channel_token: _channelToken, ...safeState } = state
|
|
168
161
|
process.stdout.write(JSON.stringify({
|
|
169
162
|
ok: true,
|
|
170
|
-
...
|
|
163
|
+
...state,
|
|
171
164
|
health_code: healthCode,
|
|
172
165
|
tmux_alive: tmuxAlive,
|
|
173
|
-
channel_ready: channelReady,
|
|
174
166
|
session_start_seen: Boolean(sessionStart),
|
|
175
|
-
channel_state_status: channelState?.status || null,
|
|
176
167
|
}))
|
|
@@ -12,20 +12,18 @@ import {
|
|
|
12
12
|
buildTurnId,
|
|
13
13
|
captureTmuxPaneArtifact,
|
|
14
14
|
classifyStopFailure,
|
|
15
|
-
detectClaudeDevelopmentChannelDisabled,
|
|
16
15
|
emitFailure,
|
|
17
16
|
emitSuccess,
|
|
18
17
|
maybeDismissClaudeRateLimitPrompt,
|
|
19
|
-
|
|
18
|
+
prepareClaudePaneForPrompt,
|
|
20
19
|
parseArgs,
|
|
21
20
|
printUsageAndExit,
|
|
22
21
|
readJsonl,
|
|
23
22
|
readPrompt,
|
|
24
23
|
readState,
|
|
25
24
|
tmuxHasSession,
|
|
26
|
-
|
|
25
|
+
tmuxPasteFileAndEnter,
|
|
27
26
|
waitForRateLimitReset,
|
|
28
|
-
waitForChannelReady,
|
|
29
27
|
waitForHookEvent,
|
|
30
28
|
writeState,
|
|
31
29
|
writeTurnArtifacts,
|
|
@@ -82,7 +80,11 @@ try {
|
|
|
82
80
|
process.exit(1)
|
|
83
81
|
}
|
|
84
82
|
|
|
85
|
-
|
|
83
|
+
const resumingRateLimitTurn = state.status === 'blocked'
|
|
84
|
+
&& Boolean(state.current_turn_id)
|
|
85
|
+
&& Boolean(state.rate_limit_wait_until || state.rate_limit_reset_at)
|
|
86
|
+
|
|
87
|
+
if (state.status !== 'idle' && !resumingRateLimitTurn) {
|
|
86
88
|
const resultPayload = buildFailureResult('claude_live_turn_not_idle', `Claude live lane is not idle: ${state.status}`, state.sid || null)
|
|
87
89
|
emitTurnFailure(resultPayload)
|
|
88
90
|
process.exit(1)
|
|
@@ -104,17 +106,6 @@ try {
|
|
|
104
106
|
process.exit(1)
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
const pane = await tmuxCapturePane(state.tmux_session)
|
|
108
|
-
if (detectClaudeDevelopmentChannelDisabled(pane)) {
|
|
109
|
-
await writeState(runtimeDir, {
|
|
110
|
-
status: 'failed',
|
|
111
|
-
last_error: 'Claude development channels are disabled in the live tmux session; restart the lane before sending a prompt.',
|
|
112
|
-
})
|
|
113
|
-
const resultPayload = buildFailureResult('claude_development_channels_disabled', 'Claude development channels are disabled in the live tmux session; restart the lane before sending a prompt.', state.sid)
|
|
114
|
-
emitTurnFailure(resultPayload)
|
|
115
|
-
process.exit(1)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
109
|
prompt = await readPrompt(promptSource)
|
|
119
110
|
originalPrompt = prompt
|
|
120
111
|
if (!prompt.trim()) {
|
|
@@ -125,34 +116,73 @@ try {
|
|
|
125
116
|
|
|
126
117
|
let hookStartIndex = (await readJsonl(paths.hookEventsFile)).length
|
|
127
118
|
const turnNumber = Number(state.last_turn_number || 0)
|
|
128
|
-
const turnId = buildTurnId(turnNumber)
|
|
119
|
+
const turnId = resumingRateLimitTurn ? state.current_turn_id : buildTurnId(turnNumber)
|
|
129
120
|
const turnDir = path.join(paths.turnsDir, turnId)
|
|
130
|
-
const stagedPromptFile =
|
|
121
|
+
const stagedPromptFile = resumingRateLimitTurn && state.current_turn_prompt_file
|
|
122
|
+
? state.current_turn_prompt_file
|
|
123
|
+
: path.join(turnDir, 'prompt.txt')
|
|
131
124
|
await fs.mkdir(turnDir, { recursive: true })
|
|
132
|
-
await fs.writeFile(stagedPromptFile, `${prompt}\n`, 'utf8')
|
|
133
125
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
126
|
+
if (resumingRateLimitTurn) {
|
|
127
|
+
const waitInfo = await waitForRateLimitReset({
|
|
128
|
+
message: state.rate_limit_message || state.last_error || 'rate_limit',
|
|
129
|
+
resetAt: state.rate_limit_reset_at || state.rate_limit_wait_until,
|
|
130
|
+
rateLimitType: state.rate_limit_type || null,
|
|
131
|
+
rawResetAt: state.rate_limit_raw_reset_at ?? null,
|
|
132
|
+
statePath: paths.stateFile,
|
|
133
|
+
})
|
|
134
|
+
prompt = buildRateLimitContinuationPrompt({ originalPrompt, waitInfo })
|
|
135
|
+
await fs.writeFile(stagedPromptFile, `${prompt}\n`, 'utf8')
|
|
136
|
+
await writeState(runtimeDir, {
|
|
137
|
+
status: 'running',
|
|
138
|
+
current_turn_id: turnId,
|
|
139
|
+
current_turn_prompt_file: stagedPromptFile,
|
|
140
|
+
current_turn_prompt_source: state.current_turn_prompt_source || promptSource,
|
|
141
|
+
current_turn_started_at: new Date().toISOString(),
|
|
142
|
+
rate_limit_continuation_prompt_file: stagedPromptFile,
|
|
143
|
+
last_error: null,
|
|
144
|
+
})
|
|
145
|
+
} else {
|
|
146
|
+
await fs.writeFile(stagedPromptFile, `${prompt}\n`, 'utf8')
|
|
147
|
+
|
|
148
|
+
await writeState(runtimeDir, {
|
|
149
|
+
status: 'running',
|
|
150
|
+
current_turn_id: turnId,
|
|
151
|
+
current_turn_prompt_file: stagedPromptFile,
|
|
152
|
+
current_turn_prompt_source: promptSource,
|
|
153
|
+
current_turn_started_at: new Date().toISOString(),
|
|
154
|
+
last_error: null,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
142
157
|
|
|
143
158
|
while (true) {
|
|
144
159
|
const liveState = await readState(runtimeDir) || state
|
|
145
160
|
|
|
146
|
-
await
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
state: liveState,
|
|
150
|
-
prompt,
|
|
151
|
-
turnId,
|
|
161
|
+
const paneReady = await prepareClaudePaneForPrompt(liveState.tmux_session || state.tmux_session, {
|
|
162
|
+
statePath: paths.stateFile,
|
|
163
|
+
timeoutMs: 30000,
|
|
152
164
|
})
|
|
153
165
|
|
|
154
|
-
if (
|
|
155
|
-
|
|
166
|
+
if (paneReady.blocker === 'rate-limit') {
|
|
167
|
+
prompt = buildRateLimitContinuationPrompt({ originalPrompt, waitInfo: paneReady.wait_info })
|
|
168
|
+
await fs.writeFile(stagedPromptFile, `${prompt}\n`, 'utf8')
|
|
169
|
+
await writeState(runtimeDir, {
|
|
170
|
+
status: 'running',
|
|
171
|
+
current_turn_id: turnId,
|
|
172
|
+
current_turn_prompt_file: stagedPromptFile,
|
|
173
|
+
current_turn_prompt_source: promptSource,
|
|
174
|
+
current_turn_started_at: new Date().toISOString(),
|
|
175
|
+
rate_limit_popup_dismissed: paneReady.popup_dismissed,
|
|
176
|
+
rate_limit_continuation_prompt_file: stagedPromptFile,
|
|
177
|
+
last_error: null,
|
|
178
|
+
})
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!paneReady.ready) {
|
|
183
|
+
const blockerFile = path.join(turnDir, `prompt-blocked-pane-${Date.now()}.txt`)
|
|
184
|
+
await captureTmuxPaneArtifact(liveState.tmux_session || state.tmux_session, blockerFile)
|
|
185
|
+
const resultPayload = buildFailureResult('claude_prompt_injection_blocked', `Claude pane is not ready for prompt injection: ${paneReady.blocker || 'unknown'}`, liveState.sid || state.sid)
|
|
156
186
|
await writeTurnArtifacts(paths, turnId, prompt, resultPayload, argv['result-file'] || null)
|
|
157
187
|
await writeState(runtimeDir, {
|
|
158
188
|
status: 'failed',
|
|
@@ -163,6 +193,7 @@ try {
|
|
|
163
193
|
last_completed_turn_id: turnId,
|
|
164
194
|
last_turn_number: turnNumber + 1,
|
|
165
195
|
last_error: resultPayload.msg,
|
|
196
|
+
prompt_blocked_pane_file: blockerFile,
|
|
166
197
|
rate_limit_wait_started_at: null,
|
|
167
198
|
rate_limit_wait_until: null,
|
|
168
199
|
rate_limit_wait_ms: null,
|
|
@@ -176,6 +207,36 @@ try {
|
|
|
176
207
|
process.exit(1)
|
|
177
208
|
}
|
|
178
209
|
|
|
210
|
+
const bufferName = `sm-${turnId}-${Date.now()}`
|
|
211
|
+
await writeState(runtimeDir, {
|
|
212
|
+
current_turn_transport: 'tmux-paste-buffer',
|
|
213
|
+
current_turn_prompt_injected_at: null,
|
|
214
|
+
current_turn_prompt_buffer: bufferName,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const sendResult = await tmuxPasteFileAndEnter(liveState.tmux_session || state.tmux_session, stagedPromptFile, bufferName)
|
|
218
|
+
if (sendResult.code !== 0) {
|
|
219
|
+
const resultPayload = buildFailureResult('tmux_prompt_send_failed', sendResult.stderr.trim() || sendResult.stdout.trim() || 'Failed to paste prompt into Claude tmux pane', liveState.sid || state.sid)
|
|
220
|
+
await writeTurnArtifacts(paths, turnId, prompt, resultPayload, argv['result-file'] || null)
|
|
221
|
+
await writeState(runtimeDir, {
|
|
222
|
+
status: 'failed',
|
|
223
|
+
current_turn_id: null,
|
|
224
|
+
current_turn_prompt_file: null,
|
|
225
|
+
current_turn_prompt_source: null,
|
|
226
|
+
current_turn_started_at: null,
|
|
227
|
+
current_turn_transport: 'tmux-paste-buffer',
|
|
228
|
+
last_completed_turn_id: turnId,
|
|
229
|
+
last_turn_number: turnNumber + 1,
|
|
230
|
+
last_error: resultPayload.msg,
|
|
231
|
+
})
|
|
232
|
+
emitTurnFailure(resultPayload)
|
|
233
|
+
process.exit(1)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await writeState(runtimeDir, {
|
|
237
|
+
current_turn_prompt_injected_at: new Date().toISOString(),
|
|
238
|
+
})
|
|
239
|
+
|
|
179
240
|
const { event, eventsLength } = await waitForHookEvent(
|
|
180
241
|
paths,
|
|
181
242
|
hookStartIndex,
|
|
@@ -229,9 +290,14 @@ try {
|
|
|
229
290
|
const tmuxSession = liveState.tmux_session || state.tmux_session
|
|
230
291
|
const blockedPaneFile = path.join(turnDir, `rate-limit-pane-${Date.now()}-blocked.txt`)
|
|
231
292
|
const dismissedPaneFile = path.join(turnDir, `rate-limit-pane-${Date.now()}-dismissed.txt`)
|
|
232
|
-
await captureTmuxPaneArtifact(tmuxSession, blockedPaneFile)
|
|
293
|
+
const blockedPane = await captureTmuxPaneArtifact(tmuxSession, blockedPaneFile)
|
|
233
294
|
const popupDismissed = await maybeDismissClaudeRateLimitPrompt(tmuxSession)
|
|
234
|
-
await captureTmuxPaneArtifact(tmuxSession, dismissedPaneFile)
|
|
295
|
+
const dismissedPane = await captureTmuxPaneArtifact(tmuxSession, dismissedPaneFile)
|
|
296
|
+
const rateLimitMessage = [
|
|
297
|
+
resultPayload.detail || resultPayload.msg,
|
|
298
|
+
blockedPane,
|
|
299
|
+
dismissedPane,
|
|
300
|
+
].filter(Boolean).join('\n\n')
|
|
235
301
|
await writeState(runtimeDir, {
|
|
236
302
|
status: 'blocked',
|
|
237
303
|
sid: resultPayload.sid || liveState.sid || state.sid,
|
|
@@ -241,9 +307,10 @@ try {
|
|
|
241
307
|
rate_limit_popup_dismissed: popupDismissed,
|
|
242
308
|
rate_limit_blocked_pane_file: blockedPaneFile,
|
|
243
309
|
rate_limit_dismissed_pane_file: dismissedPaneFile,
|
|
310
|
+
rate_limit_message_source: 'hook_and_tmux_pane',
|
|
244
311
|
})
|
|
245
312
|
const waitInfo = await waitForRateLimitReset({
|
|
246
|
-
message:
|
|
313
|
+
message: rateLimitMessage,
|
|
247
314
|
resetAt: resultPayload.rate_limit?.resetAt || null,
|
|
248
315
|
rateLimitType: resultPayload.rate_limit?.rateLimitType || null,
|
|
249
316
|
rawResetAt: resultPayload.rate_limit?.rawResetAt ?? null,
|
|
@@ -25,15 +25,17 @@ try {
|
|
|
25
25
|
const tmuxSession = argv['tmux-session'] || (existingState?.tmux_session ?? null)
|
|
26
26
|
let blockedPaneFile = null
|
|
27
27
|
let dismissedPaneFile = null
|
|
28
|
+
let blockedPane = ''
|
|
29
|
+
let dismissedPane = ''
|
|
28
30
|
let popupDismissed = false
|
|
29
31
|
|
|
30
32
|
if (tmuxSession) {
|
|
31
33
|
const artifactDir = statePath ? path.dirname(statePath) : process.cwd()
|
|
32
34
|
blockedPaneFile = path.join(artifactDir, `manual-rate-limit-pane-${Date.now()}-blocked.txt`)
|
|
33
35
|
dismissedPaneFile = path.join(artifactDir, `manual-rate-limit-pane-${Date.now()}-dismissed.txt`)
|
|
34
|
-
await captureTmuxPaneArtifact(tmuxSession, blockedPaneFile)
|
|
36
|
+
blockedPane = await captureTmuxPaneArtifact(tmuxSession, blockedPaneFile)
|
|
35
37
|
popupDismissed = await maybeDismissClaudeRateLimitPrompt(tmuxSession)
|
|
36
|
-
await captureTmuxPaneArtifact(tmuxSession, dismissedPaneFile)
|
|
38
|
+
dismissedPane = await captureTmuxPaneArtifact(tmuxSession, dismissedPaneFile)
|
|
37
39
|
if (statePath) {
|
|
38
40
|
await writeJsonIfNeeded(statePath, {
|
|
39
41
|
...(existingState || {}),
|
|
@@ -46,7 +48,7 @@ try {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
const waitInfo = await waitForRateLimitReset({
|
|
49
|
-
message: argv.message || '',
|
|
51
|
+
message: [argv.message || '', blockedPane, dismissedPane].filter(Boolean).join('\n\n'),
|
|
50
52
|
statePath,
|
|
51
53
|
dryRun: argv['dry-run'] === '1',
|
|
52
54
|
bufferMs: argv['buffer-ms'] ? Number.parseInt(argv['buffer-ms'], 10) : undefined,
|
|
@@ -516,7 +516,24 @@ function buildIsoFromParts({ year, month, day }, hours, minutes, offsetToken) {
|
|
|
516
516
|
return `${year}-${paddedMonth}-${paddedDay}T${paddedHours}:${paddedMinutes}:00${offsetToken}`
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
function getOffsetTokenForTimeZone(date, timeZone) {
|
|
520
|
+
try {
|
|
521
|
+
const zonedParts = getZonedParts(date, timeZone)
|
|
522
|
+
const asUtc = Date.UTC(zonedParts.year, zonedParts.month - 1, zonedParts.day, zonedParts.hour, zonedParts.minute, zonedParts.second)
|
|
523
|
+
const offsetMinutes = Math.round((asUtc - date.getTime()) / 60000)
|
|
524
|
+
const sign = offsetMinutes >= 0 ? '+' : '-'
|
|
525
|
+
const absolute = Math.abs(offsetMinutes)
|
|
526
|
+
const hours = String(Math.floor(absolute / 60)).padStart(2, '0')
|
|
527
|
+
const minutes = String(absolute % 60).padStart(2, '0')
|
|
528
|
+
return `${sign}${hours}:${minutes}`
|
|
529
|
+
} catch {
|
|
530
|
+
return null
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
519
534
|
function parseTimeOnlyCandidate(candidate, now) {
|
|
535
|
+
const timeZoneMatch = candidate.match(/\(([A-Za-z_]+\/[A-Za-z_]+(?:\/[A-Za-z_]+)?)\)/)
|
|
536
|
+
const explicitTimeZone = timeZoneMatch?.[1] || null
|
|
520
537
|
const match = candidate.match(/\b(today|tomorrow)?\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*(UTC|GMT|Z|[+-]\d{2}:?\d{2}))?\b/i)
|
|
521
538
|
if (!match) {
|
|
522
539
|
return null
|
|
@@ -526,7 +543,7 @@ function parseTimeOnlyCandidate(candidate, now) {
|
|
|
526
543
|
let hours = Number(match[2])
|
|
527
544
|
const minutes = Number(match[3] || '0')
|
|
528
545
|
const meridiem = match[4]?.toLowerCase() || null
|
|
529
|
-
const offsetToken = normalizeOffset(match[5] || null)
|
|
546
|
+
const offsetToken = normalizeOffset(match[5] || null) || (explicitTimeZone ? getOffsetTokenForTimeZone(now, explicitTimeZone) : null)
|
|
530
547
|
|
|
531
548
|
if (meridiem) {
|
|
532
549
|
if (hours === 12) hours = 0
|
package/package.json
CHANGED
package/src/constants.js
CHANGED
|
@@ -55,7 +55,6 @@ export const REQUIRED_SLOPMACHINE_FILES = [
|
|
|
55
55
|
"templates/CLAUDE.md",
|
|
56
56
|
"utils/claude_worker_common.mjs",
|
|
57
57
|
"utils/claude_live_common.mjs",
|
|
58
|
-
"utils/claude_live_channel.mjs",
|
|
59
58
|
"utils/claude_live_hook.py",
|
|
60
59
|
"utils/claude_live_launch.mjs",
|
|
61
60
|
"utils/claude_live_turn.mjs",
|
package/src/install.js
CHANGED
|
@@ -207,7 +207,8 @@ const OPENCODE_EVALUATOR_AGENT = {
|
|
|
207
207
|
description: 'Static evaluator for immutable delivery audit reports',
|
|
208
208
|
mode: 'subagent',
|
|
209
209
|
model: 'openai/gpt-5.3-codex',
|
|
210
|
-
|
|
210
|
+
variant: 'medium',
|
|
211
|
+
thinkingLevel: 'medium',
|
|
211
212
|
permission: {
|
|
212
213
|
bash: 'deny',
|
|
213
214
|
edit: 'deny',
|
|
@@ -1,188 +0,0 @@
|
|
|
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
|
-
})
|