thinkpool-pair 0.7.7 → 0.7.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/bridge.mjs +12 -10
- package/claude-session.mjs +26 -7
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -457,14 +457,16 @@ function printLocal(evt) {
|
|
|
457
457
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
458
458
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
459
459
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
460
|
-
function openStructured({ id, model, resume, log, commands }) {
|
|
460
|
+
function openStructured({ id, model, resume, log, commands, mode }) {
|
|
461
461
|
if (sessions.has(id)) return
|
|
462
|
-
const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined }
|
|
462
|
+
const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode }
|
|
463
463
|
sessions.set(id, entry)
|
|
464
464
|
if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
|
|
465
|
-
|
|
465
|
+
// Persist the permission mode alongside the transcript so a bridge restart
|
|
466
|
+
// restores the session in the SAME mode (a bypass room stays bypass on resume).
|
|
467
|
+
const persist = () => saveSession(room, id, { sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode })
|
|
466
468
|
entry.session = startClaudeSession({
|
|
467
|
-
cwd: process.cwd(), model, resume,
|
|
469
|
+
cwd: process.cwd(), model, resume, mode,
|
|
468
470
|
onEvent: (evt) => {
|
|
469
471
|
// Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
|
|
470
472
|
// keeping the transcript (scrollback survives; live context is gone).
|
|
@@ -473,7 +475,7 @@ function openStructured({ id, model, resume, log, commands }) {
|
|
|
473
475
|
process.stderr.write(`\n ◆ saved session expired — starting fresh (transcript kept).\n`)
|
|
474
476
|
try { entry.session?.end() } catch { /* noop */ }
|
|
475
477
|
sessions.delete(id)
|
|
476
|
-
openStructured({ id, model, log: entry.log, commands: entry.commands })
|
|
478
|
+
openStructured({ id, model, log: entry.log, commands: entry.commands, mode: entry.mode })
|
|
477
479
|
return
|
|
478
480
|
}
|
|
479
481
|
// Stamp a wall-clock ts on every transcript event so the web client can
|
|
@@ -580,8 +582,8 @@ channel
|
|
|
580
582
|
if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
|
|
581
583
|
}
|
|
582
584
|
if (wantStructured(payload.cmd)) {
|
|
583
|
-
openStructured({ id: payload.id })
|
|
584
|
-
process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session.\n`)
|
|
585
|
+
openStructured({ id: payload.id, mode: payload.mode })
|
|
586
|
+
process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}).\n`)
|
|
585
587
|
return
|
|
586
588
|
}
|
|
587
589
|
openTerm({ id: payload.id, cmd: payload.cmd, args })
|
|
@@ -632,7 +634,7 @@ channel
|
|
|
632
634
|
// ── structured-session control (Phase 2) ──
|
|
633
635
|
.on('broadcast', { event: 'code-open' }, ({ payload }) => {
|
|
634
636
|
if (payload?.host && payload.host !== name) return
|
|
635
|
-
openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume })
|
|
637
|
+
openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume, mode: payload?.mode })
|
|
636
638
|
})
|
|
637
639
|
.on('broadcast', { event: 'code-turn' }, ({ payload }) => {
|
|
638
640
|
const s = payload?.term && sessions.get(payload.term)
|
|
@@ -686,7 +688,7 @@ channel
|
|
|
686
688
|
})
|
|
687
689
|
.on('broadcast', { event: 'code-mode' }, ({ payload }) => {
|
|
688
690
|
const s = payload?.term && sessions.get(payload.term)
|
|
689
|
-
if (s) s.session.setMode(payload.mode)
|
|
691
|
+
if (s) { s.mode = payload.mode; s.session.setMode(payload.mode) } // track for persist/restore
|
|
690
692
|
})
|
|
691
693
|
.on('broadcast', { event: 'code-close' }, ({ payload }) => {
|
|
692
694
|
const s = payload?.id && sessions.get(payload.id)
|
|
@@ -704,7 +706,7 @@ channel
|
|
|
704
706
|
// replay its transcript + resume the live SDK context if recent enough.
|
|
705
707
|
if (wantStructured(startCmd)) {
|
|
706
708
|
const prev = loadLatest(room)
|
|
707
|
-
if (prev && (prev.log?.length || prev.sessionId)) openStructured({ id: prev.id, resume: canResume(prev) ? prev.sessionId : undefined, log: prev.log, commands: prev.commands })
|
|
709
|
+
if (prev && (prev.log?.length || prev.sessionId)) openStructured({ id: prev.id, resume: canResume(prev) ? prev.sessionId : undefined, log: prev.log, commands: prev.commands, mode: prev.mode })
|
|
708
710
|
else openStructured({ id: randomUUID() })
|
|
709
711
|
}
|
|
710
712
|
else openTerm({ id: randomUUID(), cmd: startCmd, args: attachedArgs, attached: !autoAgent })
|
package/claude-session.mjs
CHANGED
|
@@ -113,20 +113,37 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
|
|
|
113
113
|
*/
|
|
114
114
|
const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
115
115
|
|
|
116
|
-
export function startClaudeSession({ cwd, model, resume, onEvent, requestPermission }) {
|
|
116
|
+
export function startClaudeSession({ cwd, model, resume, mode: initialMode = 'default', onEvent, requestPermission }) {
|
|
117
117
|
const ac = new AbortController()
|
|
118
118
|
const input = makeInputStream()
|
|
119
119
|
let sessionId = resume || null
|
|
120
120
|
let closed = false
|
|
121
121
|
let q = null // the live Query — control requests (interrupt /
|
|
122
122
|
// setPermissionMode) route through it once streaming.
|
|
123
|
-
|
|
123
|
+
// The session is CREATED in the caller's chosen mode (not hard-coded default):
|
|
124
|
+
// a bypass session must start in bypass so the agent never enters plan mode and
|
|
125
|
+
// never throws an ExitPlanMode card. Falls back to 'default' for unknown values.
|
|
126
|
+
let mode = MODES.has(initialMode) ? initialMode : 'default' // mirrors ⇧⇥ cycle
|
|
124
127
|
const alwaysAllow = new Set() // tool:risk signatures the user chose "don't ask again" for
|
|
125
128
|
const toolStart = new Map() // tool_use id → start time, for the duration badge
|
|
126
129
|
let effort = null // active reasoning effort for the turn (from the hook input)
|
|
127
130
|
|
|
128
131
|
const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
|
|
129
132
|
|
|
133
|
+
// Apply a permission-mode change to the live SDK *outside* any hook callback.
|
|
134
|
+
// setPermissionMode is a streaming control request; awaiting it from INSIDE the
|
|
135
|
+
// PreToolUse hook (which the SDK is itself awaiting) jams the control channel —
|
|
136
|
+
// the call throws, and when that was silently swallowed the SDK stayed in plan
|
|
137
|
+
// mode, so the agent got bounced back to planning after every approved edit
|
|
138
|
+
// (the "PLAN READY every turn" loop). Deferring to the next tick lets the hook
|
|
139
|
+
// return first, freeing the channel. Errors are logged to the bridge, not eaten.
|
|
140
|
+
const scheduleSdkMode = (next) => {
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
Promise.resolve(q?.setPermissionMode?.(next)).catch((e) =>
|
|
143
|
+
console.error(`[claude-session] setPermissionMode(${next}) failed:`, e?.message || e))
|
|
144
|
+
}, 0)
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
// PreToolUse — fires on EVERY tool call (the universal gate). The mode policy
|
|
131
148
|
// mirrors Claude Code exactly: reads never prompt (any mode); Auto-accept
|
|
132
149
|
// edits auto-approves non-destructive writes; Bash / network / destructive
|
|
@@ -150,8 +167,8 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
|
|
|
150
167
|
catch { choice = 'keep' }
|
|
151
168
|
if (choice === 'run' || choice === 'accept') {
|
|
152
169
|
const next = choice === 'accept' ? 'acceptEdits' : 'default'
|
|
153
|
-
mode = next
|
|
154
|
-
|
|
170
|
+
mode = next // our auto-allow gate updates immediately
|
|
171
|
+
scheduleSdkMode(next) // flip the SDK AFTER this hook returns (see above)
|
|
155
172
|
emit({ kind: 'mode', mode: next })
|
|
156
173
|
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Plan approved in the ThinkPool room — proceed (${next} mode).` } }
|
|
157
174
|
}
|
|
@@ -211,7 +228,7 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
|
|
|
211
228
|
|
|
212
229
|
const opts = {
|
|
213
230
|
abortController: ac,
|
|
214
|
-
permissionMode:
|
|
231
|
+
permissionMode: mode,
|
|
215
232
|
hooks: { PreToolUse: [{ hooks: [preTool] }] },
|
|
216
233
|
// Load the host's REAL Claude environment — user + project + local settings —
|
|
217
234
|
// so custom slash commands (.claude/commands/*.md), CLAUDE.md and agents work
|
|
@@ -298,8 +315,10 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
|
|
|
298
315
|
// `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
|
|
299
316
|
async setMode(m) {
|
|
300
317
|
if (!MODES.has(m) || closed) return
|
|
301
|
-
mode = m
|
|
302
|
-
|
|
318
|
+
mode = m // gate is authoritative immediately
|
|
319
|
+
scheduleSdkMode(m) // defer the SDK control request — a mode toggle can
|
|
320
|
+
// land while a perm card is pending (hook in flight),
|
|
321
|
+
// which is exactly the re-entrancy that jams it.
|
|
303
322
|
emit({ kind: 'mode', mode })
|
|
304
323
|
},
|
|
305
324
|
// /model is disabled in headless SDK — switch via the setModel control
|