thinkpool-pair 0.7.254 → 0.7.255
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 +2 -2
- package/cross-terminal.mjs +20 -0
- package/hermes-policy.mjs +7 -3
- package/hermes-session.mjs +35 -4
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -2141,7 +2141,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2141
2141
|
// (reset per human turn alongside the in-room budgets) + TP_CROSSROOM_OFF kill-switch.
|
|
2142
2142
|
tool(
|
|
2143
2143
|
'list_sessions',
|
|
2144
|
-
'List
|
|
2144
|
+
'List OTHER ThinkPool Code sessions (rooms) reachable from this room: your own rooms on this machine plus your partner\'s shared rooms over the pair bus. Returns each room\'s code, name, and partner host when remote so you can read into one with read_session. This is Thinkpool Ensemble across SESSIONS, not just terminals. Read-only.',
|
|
2145
2145
|
{},
|
|
2146
2146
|
async () => {
|
|
2147
2147
|
const okText = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
@@ -2153,7 +2153,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2153
2153
|
),
|
|
2154
2154
|
tool(
|
|
2155
2155
|
'read_session',
|
|
2156
|
-
'Read-only view INTO another
|
|
2156
|
+
'Read-only view INTO another ThinkPool Code session returned by list_sessions—either your own room on this machine or your partner\'s shared room over the pair bus. Pass `session` (the room code) and optionally `terminal` (a ref/name in that room) to read its recent activity; omit `terminal` to list that room\'s terminals. It never changes the other session.',
|
|
2157
2157
|
{
|
|
2158
2158
|
session: z.string().describe('the room code of the other session (from list_sessions)'),
|
|
2159
2159
|
terminal: z.string().optional().describe('ref, id, or name of a terminal in that room; omit to list that room\'s terminals'),
|
package/cross-terminal.mjs
CHANGED
|
@@ -606,6 +606,26 @@ export const CROSSROOM = {
|
|
|
606
606
|
peekPerTurnCap: 5, // list_sessions + read_session calls allowed per turn (vs PEEK.perTurnCap=10 in-room)
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
// Canonical ThinkPool peer capability names. Claude/Codex receive the SDK MCP
|
|
610
|
+
// server directly; Hermes reconstructs a process-local schema and therefore
|
|
611
|
+
// needs the same names explicitly. Keep the peer surface here beside its gates
|
|
612
|
+
// so a runtime cannot silently lose cross-room reach when the server grows.
|
|
613
|
+
export const PEER_READ_MCP_TOOLS = Object.freeze([
|
|
614
|
+
'read_terminal',
|
|
615
|
+
'list_sessions',
|
|
616
|
+
'read_session',
|
|
617
|
+
])
|
|
618
|
+
|
|
619
|
+
export const PEER_POST_MCP_TOOLS = Object.freeze([
|
|
620
|
+
'post_to_terminal',
|
|
621
|
+
'post_to_session',
|
|
622
|
+
])
|
|
623
|
+
|
|
624
|
+
export const PEER_MCP_TOOLS = Object.freeze([
|
|
625
|
+
...PEER_READ_MCP_TOOLS,
|
|
626
|
+
...PEER_POST_MCP_TOOLS,
|
|
627
|
+
])
|
|
628
|
+
|
|
609
629
|
// Render the list_sessions roster from the supervisor's room list. `rooms` is
|
|
610
630
|
// [{ code, name }]; `thisRoom` is excluded defensively (the supervisor already
|
|
611
631
|
// drops it). Mirrors formatPeek's no-arg roster shape so the agent reads one voice.
|
package/hermes-policy.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Bridge-owned Hermes ACP schema policy. This is intentionally a small, pure
|
|
2
2
|
// contract: Python receives only this JSON and fails closed for anything else.
|
|
3
|
+
import { PEER_MCP_TOOLS, PEER_READ_MCP_TOOLS } from './cross-terminal.mjs'
|
|
4
|
+
|
|
3
5
|
export const HERMES_POLICY_VERSION = 1
|
|
4
6
|
|
|
5
7
|
export const CODING_TOOLS = Object.freeze([
|
|
@@ -20,7 +22,7 @@ export const ESSENTIAL_CODING_TOOLS = Object.freeze([
|
|
|
20
22
|
'terminal', 'process', 'read_file', 'write_file', 'patch', 'search_files',
|
|
21
23
|
])
|
|
22
24
|
const ROLE_REQUIRED = Object.freeze({
|
|
23
|
-
ordinary: [
|
|
25
|
+
ordinary: [...PEER_MCP_TOOLS, 'spawn_terminal', 'close_terminal'],
|
|
24
26
|
plan: [],
|
|
25
27
|
conductor: ['submit_flow_plan'],
|
|
26
28
|
builder: ['mark_flow_done'],
|
|
@@ -43,7 +45,7 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
|
|
|
43
45
|
// Ordinary worker leaves deliberately lack spawn/close. Main-lane proof is
|
|
44
46
|
// enforced by requiredMcpTools at dispatch; keep this schema usable for a
|
|
45
47
|
// non-delegating ordinary child without widening it.
|
|
46
|
-
const minimum = role === 'ordinary' ?
|
|
48
|
+
const minimum = role === 'ordinary' ? PEER_MCP_TOOLS : required
|
|
47
49
|
for (const tool of minimum) if (!supplied.includes(tool)) throw new Error(`Hermes ${role} policy is missing required ThinkPool tool ${tool}`)
|
|
48
50
|
const restricted = role === 'plan' || role === 'conductor' || role === 'reviewer' || role === 'manual-review'
|
|
49
51
|
const builtinTools = restricted ? READ_ONLY_TOOLS : CODING_TOOLS
|
|
@@ -61,10 +63,12 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
export function hermesRequiredMcpTools(role, { canSpawnWorkers = false } = {}) {
|
|
64
|
-
if (role === 'ordinary') return [
|
|
66
|
+
if (role === 'ordinary') return [...PEER_MCP_TOOLS, ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
|
|
65
67
|
return [...ROLE_REQUIRED[role]]
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
export const HERMES_PLAN_SAFE_MCP_TOOLS = PEER_READ_MCP_TOOLS
|
|
71
|
+
|
|
68
72
|
// Keep the bridge's local /tools proof on the same canonical inventory as the
|
|
69
73
|
// process-local Python bootstrap. `allBuiltinTools` lets that proof reject a
|
|
70
74
|
// known built-in which is not part of this role, rather than merely looking
|
package/hermes-session.mjs
CHANGED
|
@@ -6,15 +6,16 @@ import { randomUUID } from 'node:crypto'
|
|
|
6
6
|
import { AcpClient } from './acp-client.mjs'
|
|
7
7
|
import { HermesEventMapper, hermesToolFor } from './hermes-event-mapper.mjs'
|
|
8
8
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
9
|
-
import { hermesExactInventory, hermesPolicyEnv, hermesRoleFor } from './hermes-policy.mjs'
|
|
9
|
+
import { HERMES_PLAN_SAFE_MCP_TOOLS, hermesExactInventory, hermesPolicyEnv, hermesRoleFor } from './hermes-policy.mjs'
|
|
10
10
|
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
11
11
|
import { autoAllow, classifyRisk } from './claude-session.mjs'
|
|
12
|
+
import { crossPostNeedsCard } from './cross-terminal.mjs'
|
|
12
13
|
|
|
13
14
|
export const HERMES_COMMAND = 'thinkpool'
|
|
14
15
|
export const HERMES_ACP_PROTOCOL_VERSION = 1
|
|
15
16
|
export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
16
17
|
const HERMES_INITIALIZE_TIMEOUT_MS = 15_000
|
|
17
|
-
const PLAN_SAFE_MCP_TOOLS = new Set([
|
|
18
|
+
const PLAN_SAFE_MCP_TOOLS = new Set([...HERMES_PLAN_SAFE_MCP_TOOLS, 'read_review_file'])
|
|
18
19
|
|
|
19
20
|
const HERMES_SECRET_ENV_KEY = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i
|
|
20
21
|
const HERMES_REPLAY_UPDATES = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan'])
|
|
@@ -48,6 +49,7 @@ export function startHermesSession({
|
|
|
48
49
|
roomContext, terminalRolePrompt, rolePrompt, mcpServers, requiredMcpTools = [], prepareCwd = null,
|
|
49
50
|
command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
|
|
50
51
|
mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
|
|
52
|
+
crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null,
|
|
51
53
|
} = {}) {
|
|
52
54
|
let activeCwd = cwd
|
|
53
55
|
const requestedModel = model || null
|
|
@@ -87,20 +89,49 @@ export function startHermesSession({
|
|
|
87
89
|
|
|
88
90
|
const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
|
|
89
91
|
|
|
92
|
+
const thinkpoolPeerTool = (name) => {
|
|
93
|
+
const lower = String(name || '').toLowerCase()
|
|
94
|
+
return [...HERMES_PLAN_SAFE_MCP_TOOLS, 'post_to_terminal', 'post_to_session']
|
|
95
|
+
.find((candidate) => lower === candidate || lower === `mcp__thinkpool__${candidate}` || lower === `mcp_thinkpool_${candidate}`) || null
|
|
96
|
+
}
|
|
97
|
+
|
|
90
98
|
async function onRequest(method, params, requestId) {
|
|
91
99
|
if (method !== 'session/request_permission') throw Object.assign(new Error(`Unsupported ACP client method: ${method}`), { code: -32601 })
|
|
92
100
|
const tool = hermesToolFor(params.toolCall || mapper?.tools.get(params.toolCallId) || {})
|
|
101
|
+
const peerTool = thinkpoolPeerTool(tool.name)
|
|
93
102
|
const card = {
|
|
94
103
|
id: `hermes-perm:${requestId}`,
|
|
95
|
-
toolName: tool.name,
|
|
104
|
+
toolName: peerTool ? `mcp__thinkpool__${peerTool}` : tool.name,
|
|
96
105
|
input: tool.input,
|
|
97
|
-
risk: classifyRisk(tool.name, tool.input),
|
|
106
|
+
risk: peerTool?.startsWith('post_to_') ? 'high' : classifyRisk(tool.name, tool.input),
|
|
98
107
|
}
|
|
99
108
|
// ACP's native modes currently govern edit proposals only. ThinkPool's
|
|
100
109
|
// permission chip is the cross-runtime authority, so apply the same pure
|
|
101
110
|
// mode policy Claude uses before raising a durable room card. Structural
|
|
102
111
|
// role schemas still win: bypass can auto-allow a request, but it cannot
|
|
103
112
|
// restore a tool that the process-local Hermes policy never exposed.
|
|
113
|
+
// Match Claude/Codex peer semantics before the generic permission matrix.
|
|
114
|
+
// Paired-room reads are always read-only, including in Plan. Same-room posts
|
|
115
|
+
// honor bypass/ownership after their bridge gate. Cross-room posts always
|
|
116
|
+
// retain sender consent—even in bypass—because the target has a second card.
|
|
117
|
+
if (HERMES_PLAN_SAFE_MCP_TOOLS.includes(peerTool)) {
|
|
118
|
+
return { outcome: permissionOutcome('allow', params.options) }
|
|
119
|
+
}
|
|
120
|
+
if (peerTool === 'post_to_terminal') {
|
|
121
|
+
const gate = crossPostGate ? crossPostGate() : { ok: true }
|
|
122
|
+
if (!gate.ok) return { outcome: { outcome: 'cancelled' } }
|
|
123
|
+
const spawnedByMe = didSpawnTarget ? !!didSpawnTarget(tool.input?.terminal) : false
|
|
124
|
+
if (!crossPostNeedsCard({ mode: activeMode, spawnedByMe })) {
|
|
125
|
+
return { outcome: permissionOutcome('allow', params.options) }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (peerTool === 'post_to_session') {
|
|
129
|
+
const gate = crossRoomPostGate ? crossRoomPostGate() : { ok: true }
|
|
130
|
+
if (!gate.ok) return { outcome: { outcome: 'cancelled' } }
|
|
131
|
+
let decision = 'deny'
|
|
132
|
+
try { decision = await requestPermission?.(card) } catch { /* fail closed */ }
|
|
133
|
+
return { outcome: permissionOutcome(decision, params.options) }
|
|
134
|
+
}
|
|
104
135
|
if (activeMode === 'plan') return { outcome: { outcome: 'cancelled' } }
|
|
105
136
|
if (autoAllow({ toolName: card.toolName, input: card.input, mode: activeMode })) {
|
|
106
137
|
return { outcome: permissionOutcome('allow', params.options) }
|