thinkpool-pair 0.7.82 → 0.7.84
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 +53 -4
- package/claude-session.mjs +21 -6
- package/cross-terminal.mjs +74 -14
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -39,7 +39,7 @@ import { createClient } from '@supabase/supabase-js'
|
|
|
39
39
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
40
40
|
import { z } from 'zod'
|
|
41
41
|
import { startClaudeSession } from './claude-session.mjs'
|
|
42
|
-
import { formatPeek, PEEK } from './cross-terminal.mjs'
|
|
42
|
+
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST } from './cross-terminal.mjs'
|
|
43
43
|
import { turnInFlight } from './update-gate.mjs'
|
|
44
44
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
|
|
45
45
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
|
|
@@ -921,15 +921,58 @@ function openStructured({ id, model, resume, log, commands, mode }) {
|
|
|
921
921
|
if (entry.peekCount > PEEK.perTurnCap) {
|
|
922
922
|
return { content: [{ type: 'text', text: `Cross-terminal read limit reached for this turn (${PEEK.perTurnCap}). Continue with what you have, or ask the people in the room.` }] }
|
|
923
923
|
}
|
|
924
|
-
const text = formatPeek({ selfId: id, sessions, terms, terminal: args?.terminal, lines: args?.lines })
|
|
924
|
+
const text = formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args?.terminal, lines: args?.lines })
|
|
925
925
|
return { content: [{ type: 'text', text }] }
|
|
926
926
|
},
|
|
927
927
|
),
|
|
928
|
+
// Tier C — directed cross-lane action. WRITES into a sibling AGENT lane.
|
|
929
|
+
// The PreToolUse gate (claude-session.mjs) already enforced: precheck passed
|
|
930
|
+
// + a human approved the card. This handler routes only; it re-asserts the
|
|
931
|
+
// bounds defensively. Targets AGENT terminals only (not human shells —
|
|
932
|
+
// injecting keystrokes into someone's shell is out of scope for v1).
|
|
933
|
+
tool(
|
|
934
|
+
'post_to_terminal',
|
|
935
|
+
'Send a message or task to ANOTHER AGENT terminal in this ThinkPool Code room. Use `terminal` (a ref/id/command from read_terminal) and `text` (what to send). A person in the room must approve before it is delivered, and an agent reached via a cross-post cannot post onward. Targets agent terminals only, not plain shells. Use read_terminal first to understand the sibling.',
|
|
936
|
+
{
|
|
937
|
+
terminal: z.string().describe('ref, id, or command of the AGENT terminal to send to (from read_terminal)'),
|
|
938
|
+
text: z.string().describe('the message or task to deliver to that terminal'),
|
|
939
|
+
},
|
|
940
|
+
async (args) => {
|
|
941
|
+
const okText = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
942
|
+
// Defensive re-check (the gate already passed in PreToolUse).
|
|
943
|
+
const gate = crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' })
|
|
944
|
+
if (!gate.ok) return okText(gate.reason)
|
|
945
|
+
const sib = siblingsOf({ selfId: id, sessions, terms, names: termNames })
|
|
946
|
+
const target = resolveSibling(sib, args?.terminal)
|
|
947
|
+
if (!target) return okText(`No terminal matches "${args?.terminal}". Call read_terminal with no argument to list the open terminals.`)
|
|
948
|
+
if (target.kind !== 'agent') return okText(`Terminal ${target.ref} is a shell, not an agent — cross-posting is only allowed to agent terminals.`)
|
|
949
|
+
const te = sessions.get(target.id)
|
|
950
|
+
if (!te?.session) return okText(`Terminal ${target.ref} is no longer live.`)
|
|
951
|
+
entry.postCount = (entry.postCount || 0) + 1
|
|
952
|
+
const fromRef = String(id).slice(0, 8)
|
|
953
|
+
const msg = `[From: terminal ${fromRef}'s agent — relayed via ThinkPool cross-terminal, approved by a person in the room]\n${args.text}`
|
|
954
|
+
// The injected turn starts fresh budgets, one hop deeper (loop breaker).
|
|
955
|
+
te.hop = (entry.hop || 0) + 1
|
|
956
|
+
te.peekCount = 0
|
|
957
|
+
te.postCount = 0
|
|
958
|
+
// Echo the injected prompt into the TARGET lane so both people see what
|
|
959
|
+
// arrived (rides the existing code-event 'you' path — no new topic).
|
|
960
|
+
const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true }
|
|
961
|
+
stampEvent(evt)
|
|
962
|
+
pushLog(te, evt)
|
|
963
|
+
bcast('code-event', { term: target.id, evt })
|
|
964
|
+
try { te.session.sendTurn(msg) } catch { return okText(`Could not deliver to terminal ${target.ref} — it may have just closed.`) }
|
|
965
|
+
return okText(`Delivered to terminal ${target.ref} (${target.cmd}). It will respond in its own lane; check back with read_terminal.`)
|
|
966
|
+
},
|
|
967
|
+
),
|
|
928
968
|
],
|
|
929
969
|
})
|
|
930
970
|
entry.session = startClaudeSession({
|
|
931
971
|
cwd: process.cwd(), model, resume, mode,
|
|
932
972
|
mcpServers: { thinkpool: peekServer },
|
|
973
|
+
// Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
|
|
974
|
+
// disabled/looping/over-cap post never bothers a person. Closes over `entry`.
|
|
975
|
+
crossPostGate: () => crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' }),
|
|
933
976
|
env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
|
|
934
977
|
onEvent: (evt) => {
|
|
935
978
|
// Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
|
|
@@ -1216,7 +1259,13 @@ channel
|
|
|
1216
1259
|
const s = payload?.term && sessions.get(payload.term)
|
|
1217
1260
|
if (!s || payload.text == null) return
|
|
1218
1261
|
markActivity()
|
|
1219
|
-
|
|
1262
|
+
// B2/C: a real human turn is hop 0 with fresh cross-terminal budgets. This is
|
|
1263
|
+
// the loop-breaker reset point — an injected (cross-posted) turn does NOT come
|
|
1264
|
+
// through here, so its deeper hop level + spent budget stick until a person
|
|
1265
|
+
// speaks again.
|
|
1266
|
+
s.peekCount = 0
|
|
1267
|
+
s.postCount = 0
|
|
1268
|
+
s.hop = 0
|
|
1220
1269
|
const text = String(payload.text)
|
|
1221
1270
|
// Echo the turn so BOTH readers (and late joiners, via the log) show who
|
|
1222
1271
|
// said what — UNLESS silent (an @pool fire, which renders its own line).
|
|
@@ -1225,7 +1274,7 @@ channel
|
|
|
1225
1274
|
// Display the clean body (web sends `body` when the agent `text` carries host
|
|
1226
1275
|
// file paths) + the uploaded attachments, so the partner shows the image — not
|
|
1227
1276
|
// a raw /var path. The agent already got the full `text` via sendTurn above.
|
|
1228
|
-
const evt = { kind: 'you', text: payload.body != null ? String(payload.body) : text, cid: payload.cid, by: payload.by, ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}) }
|
|
1277
|
+
const evt = { kind: 'you', text: payload.body != null ? String(payload.body) : text, cid: payload.cid, by: payload.by, ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}), ...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}) }
|
|
1229
1278
|
pushLog(s, evt)
|
|
1230
1279
|
bcast('code-event', { term: payload.term, evt })
|
|
1231
1280
|
}
|
package/claude-session.mjs
CHANGED
|
@@ -120,7 +120,7 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
|
|
|
120
120
|
*/
|
|
121
121
|
const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
122
122
|
|
|
123
|
-
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers }) {
|
|
123
|
+
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate }) {
|
|
124
124
|
const ac = new AbortController()
|
|
125
125
|
let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
|
|
126
126
|
let sessionId = resume || null
|
|
@@ -248,12 +248,26 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
248
248
|
// Surface it to the room when it changes; absent on models without effort.
|
|
249
249
|
const lvl = hookInput.effort?.level
|
|
250
250
|
if (lvl && lvl !== effort) { effort = lvl; emit({ kind: 'effort', level: effort }) }
|
|
251
|
-
// ThinkPool
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
if (typeof toolName === 'string' && toolName.startsWith('mcp__thinkpool__')) {
|
|
251
|
+
// ThinkPool cross-terminal READ (B2) is read-only + within-room — never
|
|
252
|
+
// prompt, in any mode. It still surfaces as a tool card so the room sees the peek.
|
|
253
|
+
if (toolName === 'mcp__thinkpool__read_terminal') {
|
|
255
254
|
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-terminal read).' } }
|
|
256
255
|
}
|
|
256
|
+
// ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane —
|
|
257
|
+
// human-gated EVERY time, never remembered. First a bridge-side precheck
|
|
258
|
+
// (kill-switch / loop-breaker / per-turn cap) so a blocked or runaway post
|
|
259
|
+
// never even raises a card; then the standard permission round-trip at 'high'
|
|
260
|
+
// risk (card offers Deny / Allow-once only — no "always"). Spec INV-4.
|
|
261
|
+
if (toolName === 'mcp__thinkpool__post_to_terminal') {
|
|
262
|
+
const gate = crossPostGate ? crossPostGate() : { ok: true }
|
|
263
|
+
if (!gate.ok) {
|
|
264
|
+
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: gate.reason || 'Cross-terminal post not allowed right now.' } }
|
|
265
|
+
}
|
|
266
|
+
let decision = 'deny'
|
|
267
|
+
try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
|
|
268
|
+
const allowed = decision === 'allow'
|
|
269
|
+
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Cross-terminal post approved in the ThinkPool room.' : 'Cross-terminal post denied in the room — do not retry; ask what to do instead.' } }
|
|
270
|
+
}
|
|
257
271
|
// ── Plan approval — ExitPlanMode is how the agent presents its plan in
|
|
258
272
|
// plan mode. Render a dedicated plan card (not the generic perm card) with
|
|
259
273
|
// three outcomes: run (exit → default), accept (exit → acceptEdits), keep
|
|
@@ -370,7 +384,8 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
370
384
|
'SHOW YOUR WORK, do not just describe it: the room is watched live from a phone, where a wall of text is painful to read. Whenever you build, change, or fix anything visual — a UI, a page, an HTML artifact, a chart, a diagram, a rendered result — capture a screenshot and surface the PNG (for example, read the image file into your turn) so it appears inline in the room. The room auto-uploads any image you surface. Default to showing a picture of the result over narrating it; err toward more screenshots, not fewer.',
|
|
371
385
|
'HTML PREVIEWS: for any HTML you produce, give the room something it can actually open, by whichever path is available. (a) IN-ROOM PREVIEW: if a mockup render helper is present — one that writes a manifest into the directory named by $TP_MOCKUP_OUTBOX, such as the mockup-iterate render.sh script when the bridge was launched from the thinkpool repo — use it; it surfaces an inline card with desktop and mobile views that the room can expand. (b) SHAREABLE LINK: additionally or otherwise, publish the HTML to a GitHub Pages URL per the LINKS & ARTIFACTS rule above and post the link. Never leave an HTML artifact viewable only as a local path.',
|
|
372
386
|
'RUN IT, DO NOT ASK: verify your own work before calling it done. Actually run or serve what you changed, observe that it behaves correctly, and show the evidence in the room — a screenshot of the running result, the passing test output, or the real response — rather than telling the user "this should work, go test it." If you could not verify something, say exactly what is unverified. This room follows verify-before-claiming: runtime evidence you produced, not assertion.',
|
|
373
|
-
'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal.',
|
|
387
|
+
'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
|
|
388
|
+
'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
|
|
374
389
|
'WRITE PLANS INTO THE CHAT: whenever you form or revise a plan — because the room is in Plan mode, or because someone asked you to plan, design, or think it through first — write the actual plan out as a normal message in the room as you develop it: the approach, the concrete steps, the files you will touch, the open questions. The room does NOT surface plan files at all, and the plan-approval card does not reliably carry the plan text, so a plan that lives only in a plan file or only inside ExitPlanMode is INVISIBLE to the people you are working with — they just see "plan ready" with no content. The chat is the canonical place your plan lives; put it there so the room can read and react to it before you proceed.',
|
|
375
390
|
].join(' '),
|
|
376
391
|
// Needed for live thinking-token progress (SDKThinkingTokensMessage) to
|
package/cross-terminal.mjs
CHANGED
|
@@ -59,36 +59,77 @@ export const cleanPtyTail = (buf, n) => {
|
|
|
59
59
|
// Build the tool's text reply. `selfId` is excluded. `sessions` (structured) and
|
|
60
60
|
// `terms` (PTY) are the bridge's module-scope Maps (id → entry). With no
|
|
61
61
|
// `terminal`, returns the roster; with one, returns that lane's recent output.
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// ── shared sibling resolution (used by formatPeek + the cross-post handler) ──
|
|
63
|
+
export const siblingRef = (id) => String(id).slice(0, 8)
|
|
64
|
+
|
|
65
|
+
// Every terminal in the room except `selfId`, normalized. Structured sessions →
|
|
66
|
+
// kind 'agent' (carry `log`); PTY terminals → kind 'shell' (carry `buf`). `names`
|
|
67
|
+
// is the bridge's id→label map (termNames) — the HUMAN names people actually use
|
|
68
|
+
// ("Pivot", "Grug"); without it the roster is just hashes and an agent can't map
|
|
69
|
+
// "what is Pivot doing" to a lane.
|
|
70
|
+
export const siblingsOf = ({ selfId, sessions, terms, names = {} } = {}) => {
|
|
71
|
+
const nm = (id) => (names && names[id]) || ''
|
|
64
72
|
const sib = []
|
|
65
73
|
for (const [id, s] of (sessions || new Map())) {
|
|
66
74
|
if (id === selfId) continue
|
|
67
|
-
sib.push({ id, kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [] })
|
|
75
|
+
sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [] })
|
|
68
76
|
}
|
|
69
77
|
for (const [id, t] of (terms || new Map())) {
|
|
70
78
|
if (id === selfId) continue
|
|
71
|
-
sib.push({ id, kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '' })
|
|
79
|
+
sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '' })
|
|
72
80
|
}
|
|
73
|
-
|
|
81
|
+
return sib
|
|
82
|
+
}
|
|
74
83
|
|
|
75
|
-
|
|
84
|
+
// True for a POSITIONAL on-screen label ("Terminal 2", "t3", bare "2"). These
|
|
85
|
+
// renumber when a sibling closes (Terminal 3 → Terminal 2 after a delete), so the
|
|
86
|
+
// bridge — which only ever knows stable UUIDs — must NEVER resolve them, or a
|
|
87
|
+
// delete could silently re-point a cross-post at the wrong lane. We refuse them
|
|
88
|
+
// explicitly (safe miss) instead. The stable handles are the UUID/ref and the
|
|
89
|
+
// custom name (both bound to the UUID, immune to renumbering).
|
|
90
|
+
export const isOrdinalRef = (q) => /^(terminal\s*)?\d+$/i.test(q) || /^t\d+$/i.test(q)
|
|
91
|
+
|
|
92
|
+
// Resolve an agent-supplied reference to one sibling, or null. Match priority:
|
|
93
|
+
// exact id → exact name → exact short-ref → id-prefix (≥4 chars) → name substring
|
|
94
|
+
// → command substring. Name matching is what makes "Pivot" / "Grug" resolvable;
|
|
95
|
+
// positional ordinals are refused (see isOrdinalRef).
|
|
96
|
+
export const resolveSibling = (siblings, terminal) => {
|
|
97
|
+
const q = String(terminal || '').toLowerCase().trim()
|
|
98
|
+
if (!q || isOrdinalRef(q)) return null
|
|
99
|
+
const list = Array.isArray(siblings) ? siblings : []
|
|
100
|
+
return list.find(x => String(x.id).toLowerCase() === q)
|
|
101
|
+
|| list.find(x => String(x.name || '').toLowerCase() === q)
|
|
102
|
+
|| list.find(x => String(x.ref).toLowerCase() === q)
|
|
103
|
+
|| list.find(x => q.length >= 4 && String(x.id).toLowerCase().startsWith(q))
|
|
104
|
+
|| list.find(x => x.name && String(x.name).toLowerCase().includes(q))
|
|
105
|
+
|| list.find(x => String(x.cmd).toLowerCase().includes(q))
|
|
106
|
+
|| null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const label = (x) => `${x.ref}${x.name ? ` · "${x.name}"` : ''}`
|
|
110
|
+
|
|
111
|
+
export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, lines } = {}) => {
|
|
112
|
+
const n = Math.min(PEEK.maxLines, Math.max(1, Number(lines) || PEEK.defaultLines))
|
|
113
|
+
const sib = siblingsOf({ selfId, sessions, terms, names })
|
|
114
|
+
if (!sib.length) return 'No other terminals are open in this room right now.'
|
|
76
115
|
|
|
77
116
|
if (!terminal) {
|
|
78
117
|
const rows = sib.map(x => {
|
|
79
118
|
let preview
|
|
80
119
|
if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, PEEK.rosterPreview)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
|
|
81
120
|
else { preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)' }
|
|
82
|
-
return `- ${
|
|
121
|
+
return `- ${label(x)} · ${x.kind} · ${x.cmd} — ${clip(preview, PEEK.rosterPreview)}`
|
|
83
122
|
})
|
|
84
|
-
return `Other terminals in this room (call read_terminal with
|
|
123
|
+
return `Other terminals in this room (call read_terminal with a ref or name to read one):\n${rows.join('\n')}`
|
|
85
124
|
}
|
|
86
125
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
126
|
+
const hit = resolveSibling(sib, terminal)
|
|
127
|
+
if (!hit) {
|
|
128
|
+
const extra = isOrdinalRef(String(terminal || '').toLowerCase().trim())
|
|
129
|
+
? ` On-screen numbers like "Terminal N" shift when a terminal closes, so they can't identify a lane — use a terminal's name or ref instead.`
|
|
130
|
+
: ''
|
|
131
|
+
return `No terminal matches "${terminal}". Call read_terminal with no argument to list the open terminals (refer to one by its name or ref).${extra}`
|
|
132
|
+
}
|
|
92
133
|
|
|
93
134
|
let body
|
|
94
135
|
if (hit.kind === 'agent') {
|
|
@@ -96,5 +137,24 @@ export const formatPeek = ({ selfId, sessions, terms, terminal, lines } = {}) =>
|
|
|
96
137
|
} else {
|
|
97
138
|
body = cleanPtyTail(hit.buf, n).join('\n') || '(no output yet)'
|
|
98
139
|
}
|
|
99
|
-
return `Terminal ${
|
|
140
|
+
return `Terminal ${label(hit)} (${hit.kind} · ${hit.cmd}) — last ${n} lines:\n${body}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Tier C: cross-terminal POST (directed, gated cross-lane action) ──────────
|
|
144
|
+
// Pure gate for whether a session may post into a sibling lane on THIS call:
|
|
145
|
+
// kill-switch, loop-breaker (hop), per-turn cap. The routing itself (sendTurn
|
|
146
|
+
// into the target) lives in bridge.mjs — it needs the live session. Spec INV-4:
|
|
147
|
+
// no text reaches a sibling without an approved code-perm; agent-originated turns
|
|
148
|
+
// are hop-capped so two agents can't ping-pong forever.
|
|
149
|
+
export const CROSSPOST = {
|
|
150
|
+
maxHop: 1, // human turn = hop 0; an injected turn = hop 1, which CANNOT post onward
|
|
151
|
+
perTurnCap: 3, // post_to_terminal calls allowed per turn
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const crossPostDecision = ({ hop = 0, postCount = 0, disabled = false } = {}, limits = CROSSPOST) => {
|
|
155
|
+
const o = { ...CROSSPOST, ...(limits || {}) }
|
|
156
|
+
if (disabled) return { ok: false, reason: 'Cross-terminal posting is disabled in this room.' }
|
|
157
|
+
if (hop >= o.maxHop) return { ok: false, reason: 'Cross-terminal hop limit reached — an agent reached via cross-post cannot itself post onward; a person must direct the next cross-terminal action.' }
|
|
158
|
+
if (postCount >= o.perTurnCap) return { ok: false, reason: `Cross-terminal post limit reached for this turn (${o.perTurnCap}).` }
|
|
159
|
+
return { ok: true }
|
|
100
160
|
}
|