pi-checkpoint-bridge 0.1.0
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/README.md +69 -0
- package/index.js +243 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# checkpoint-bridge
|
|
2
|
+
|
|
3
|
+
A pi extension that lets sub-agent sessions ask the human user questions
|
|
4
|
+
through the main session's UI — the checkpoint channel from
|
|
5
|
+
[ADR-0001](../../docs/adr/ADR-0001-workflow-family-and-checkpoint-channel.md).
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
pi-subagents spawns sub-agent sessions in the **same process** as the main
|
|
10
|
+
session, and every session activates this extension. The relay runs over a
|
|
11
|
+
**process-global bus** — a `Symbol.for`-keyed `EventEmitter` on `globalThis` —
|
|
12
|
+
because `pi.events` turned out to be session-scoped in practice (a request
|
|
13
|
+
emitted on a sub-agent session's bus never reached the main session's
|
|
14
|
+
listener; live-tested 2026-09-06, see ADR-0001's amendment).
|
|
15
|
+
|
|
16
|
+
1. On `session_start` each instance tries to claim the host role via a
|
|
17
|
+
`globalThis` symbol. The main session always starts first, so **first claim
|
|
18
|
+
wins = main session hosts**.
|
|
19
|
+
2. A sub-agent calls the `ask_user_via_host` tool with a batch of questions.
|
|
20
|
+
3. The agent instance emits `checkpoint-bridge:request` on the bus.
|
|
21
|
+
4. The host instance renders each question as a `ctx.ui.select` (when options
|
|
22
|
+
are given) or `ctx.ui.input` dialog — one dialog at a time (requests queue
|
|
23
|
+
serially so concurrent askers never interleave).
|
|
24
|
+
5. Answers return as `checkpoint-bridge:response` and become the tool result.
|
|
25
|
+
|
|
26
|
+
Timeouts: per-question dialogs auto-dismiss after 180s (or the call's remaining
|
|
27
|
+
budget); the whole call defaults to a 300s budget.
|
|
28
|
+
|
|
29
|
+
## Fallback contract
|
|
30
|
+
|
|
31
|
+
`ask_user_via_host` never throws for environmental reasons. It returns a structured
|
|
32
|
+
result the caller can branch on:
|
|
33
|
+
|
|
34
|
+
| status | meaning | expected caller behavior |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `ok` | user answered every question | proceed |
|
|
37
|
+
| `timeout-or-cancelled` | user dismissed a dialog (or it timed out) | proceed with answers so far, or fall back to needs_input |
|
|
38
|
+
| `timeout` | call budget expired | fall back to `needs_input` |
|
|
39
|
+
| `cancelled` | tool call aborted | stop gracefully |
|
|
40
|
+
| `no-host` | no main session armed (headless/print mode) | fall back to `needs_input` |
|
|
41
|
+
| `error` | host-side dialog failure | fall back to `needs_input` |
|
|
42
|
+
|
|
43
|
+
Workflows should treat every non-`ok` status as *"produce a structured
|
|
44
|
+
`needs_input` return instead of guessing."*
|
|
45
|
+
|
|
46
|
+
In RPC mode the host's `ctx.ui` dialogs translate to the Extension UI Protocol
|
|
47
|
+
(`extension_ui_request` / `extension_ui_response`), so the same design serves
|
|
48
|
+
IDE/UI embeddings without changes.
|
|
49
|
+
|
|
50
|
+
## Scope discipline
|
|
51
|
+
|
|
52
|
+
Do not expose `ask_user_via_host` to every agent. In custom agent frontmatter, load the
|
|
53
|
+
extension narrowly:
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
extensions: [checkpoint-bridge]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`extensions: [checkpoint-bridge]` arms the relay; add `tools: "*, ext:checkpoint-bridge"`
|
|
60
|
+
in agents that should be able to *call* `ask_user_via_host`.
|
|
61
|
+
|
|
62
|
+
## Smoke test
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
node extensions/checkpoint-bridge/smoke.mjs
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Runs three in-process scenarios (hosted relay, local-host call, no-host
|
|
69
|
+
fallback) against a mocked bus and UI — no pi process needed.
|
package/index.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// checkpoint-bridge — a pi extension that lets sub-agent sessions ask the human
|
|
2
|
+
// user questions through the main session's UI.
|
|
3
|
+
//
|
|
4
|
+
// Design (docs/adr/ADR-0001-*.md): pi-subagents spawns sub-agent sessions in
|
|
5
|
+
// the SAME process as the main session, and every session activates this
|
|
6
|
+
// extension. The relay runs over a PROCESS-GLOBAL bus (a Symbol.for-keyed
|
|
7
|
+
// EventEmitter on globalThis) — NOT pi.events, which is session-scoped in
|
|
8
|
+
// practice: a request emitted on a sub-agent session's pi.events never
|
|
9
|
+
// reached the main session's listener (live test 2026-09-06), while the
|
|
10
|
+
// globalThis host claim demonstrably crossed sessions. An agent session
|
|
11
|
+
// calls the `ask_user_via_host` tool; the request travels over the global bus to the
|
|
12
|
+
// instance that owns the host claim (the main session — it always starts
|
|
13
|
+
// first), which renders the questions as ctx.ui dialogs and emits the answers
|
|
14
|
+
// back. `needs_input`-style structured returns remain the fallback when no
|
|
15
|
+
// host is armed, on timeout, or on cancellation — workflows degrade
|
|
16
|
+
// gracefully in headless runs (ctx.hasUI false in print/JSON mode).
|
|
17
|
+
//
|
|
18
|
+
// This file is loaded by pi's jiti loader; plain ESM JavaScript, no build
|
|
19
|
+
// step. The only dependency is typebox (from the repo root package.json),
|
|
20
|
+
// used for the tool's parameter schema.
|
|
21
|
+
|
|
22
|
+
import { EventEmitter } from 'node:events'
|
|
23
|
+
import { Type } from 'typebox'
|
|
24
|
+
|
|
25
|
+
const NS = 'checkpoint-bridge'
|
|
26
|
+
const REQUEST = `${NS}:request`
|
|
27
|
+
const RESPONSE = `${NS}:response`
|
|
28
|
+
// Cross-instance host claim and relay bus. Symbol.for keeps both shared across
|
|
29
|
+
// jiti module instances of this file within the one pi process.
|
|
30
|
+
const HOST_CLAIM = Symbol.for(`${NS}:host-claim`)
|
|
31
|
+
const BUS_SYMBOL = Symbol.for(`${NS}:bus`)
|
|
32
|
+
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 300_000 // overall budget for one ask_user_via_host call
|
|
34
|
+
const DIALOG_TIMEOUT_MS = 180_000 // per-question dialog budget
|
|
35
|
+
let requestCounter = 0
|
|
36
|
+
|
|
37
|
+
// pi.events turned out to be session-scoped in practice: a sub-agent session's
|
|
38
|
+
// emit never reached the main session's listener (live test 2026-09-06). The
|
|
39
|
+
// host claim on globalThis DID cross sessions, so the relay uses the same
|
|
40
|
+
// process-global mechanism.
|
|
41
|
+
function sharedBus() {
|
|
42
|
+
if (!globalThis[BUS_SYMBOL]) {
|
|
43
|
+
const emitter = new EventEmitter()
|
|
44
|
+
emitter.setMaxListeners(64)
|
|
45
|
+
globalThis[BUS_SYMBOL] = emitter
|
|
46
|
+
}
|
|
47
|
+
return globalThis[BUS_SYMBOL]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sessionRef(ctx) {
|
|
51
|
+
try {
|
|
52
|
+
const file = ctx?.sessionManager?.getSessionFile?.()
|
|
53
|
+
return file || `ephemeral-${process.pid}-${requestCounter}`
|
|
54
|
+
} catch {
|
|
55
|
+
return `unknown-${process.pid}`
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hostSessionId() {
|
|
60
|
+
return globalThis[HOST_CLAIM]?.sessionId ?? null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dialogTimeoutFor(request) {
|
|
64
|
+
return Math.max(5_000, Math.min(DIALOG_TIMEOUT_MS, request.dialogTimeoutMs ?? DIALOG_TIMEOUT_MS))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Render one batch of questions as dialogs. Returns {status, answers}.
|
|
68
|
+
// `undefined` from a dialog means timeout or dismissal.
|
|
69
|
+
async function runDialogs(questions, ctx, dialogTimeoutMs) {
|
|
70
|
+
const answers = []
|
|
71
|
+
for (const q of questions) {
|
|
72
|
+
let answer
|
|
73
|
+
if (Array.isArray(q.options) && q.options.length > 0) {
|
|
74
|
+
answer = await ctx.ui.select(q.question ?? 'Choose one:', q.options.map(String), { timeout: dialogTimeoutMs })
|
|
75
|
+
} else {
|
|
76
|
+
answer = await ctx.ui.input(q.question ?? 'Answer:', q.placeholder ?? 'type your answer…', { timeout: dialogTimeoutMs })
|
|
77
|
+
}
|
|
78
|
+
if (answer === undefined) {
|
|
79
|
+
return { status: 'timeout-or-cancelled', answers }
|
|
80
|
+
}
|
|
81
|
+
answers.push({ question: q.question, answer })
|
|
82
|
+
}
|
|
83
|
+
return { status: 'ok', answers }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default function (pi) {
|
|
87
|
+
const state = {
|
|
88
|
+
sessionId: null,
|
|
89
|
+
ctx: null,
|
|
90
|
+
queue: Promise.resolve(), // host-side serial dialog queue (one dialog at a time)
|
|
91
|
+
listeners: [], // bus listeners owned by this instance, removed on shutdown
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const bus = sharedBus()
|
|
95
|
+
const on = (event, handler) => {
|
|
96
|
+
bus.on(event, handler)
|
|
97
|
+
state.listeners.push({ event, handler })
|
|
98
|
+
}
|
|
99
|
+
const off = (event, handler) => {
|
|
100
|
+
if (typeof bus.off === 'function') bus.off(event, handler)
|
|
101
|
+
else if (typeof bus.removeListener === 'function') bus.removeListener(event, handler)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function claimHostIfFree() {
|
|
105
|
+
if (!globalThis[HOST_CLAIM]) {
|
|
106
|
+
globalThis[HOST_CLAIM] = { sessionId: state.sessionId, pid: process.pid }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function releaseHostIfMine() {
|
|
111
|
+
const claim = globalThis[HOST_CLAIM]
|
|
112
|
+
if (claim && claim.sessionId === state.sessionId && claim.pid === process.pid) {
|
|
113
|
+
delete globalThis[HOST_CLAIM]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pi.on('session_start', (_event, ctx) => {
|
|
118
|
+
state.sessionId = sessionRef(ctx)
|
|
119
|
+
state.ctx = ctx
|
|
120
|
+
// The main session always runs session_start before any sub-agent session
|
|
121
|
+
// can exist, so first claim wins = main session hosts.
|
|
122
|
+
claimHostIfFree()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
pi.on('session_shutdown', () => {
|
|
126
|
+
releaseHostIfMine()
|
|
127
|
+
for (const l of state.listeners) off(l.event, l.handler)
|
|
128
|
+
state.listeners = []
|
|
129
|
+
state.ctx = null
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// Host side: every instance hears requests; only the claim owner acts, and
|
|
133
|
+
// it serializes dialogs so concurrent askers queue instead of interleaving.
|
|
134
|
+
on(REQUEST, (request) => {
|
|
135
|
+
if (!request || hostSessionId() !== state.sessionId) return
|
|
136
|
+
state.queue = state.queue
|
|
137
|
+
.then(async () => {
|
|
138
|
+
const ctx = state.ctx
|
|
139
|
+
if (!ctx || !ctx.hasUI) {
|
|
140
|
+
bus.emit(RESPONSE, { requestId: request.requestId, status: 'no-host', answers: [] })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
// Fire-and-forget receipt proof: visible even if the dialog itself
|
|
144
|
+
// fails to render.
|
|
145
|
+
try {
|
|
146
|
+
ctx.ui.notify?.(`checkpoint-bridge: a sub-agent is asking ${request.questions?.length ?? 0} question(s)`, 'info')
|
|
147
|
+
} catch {}
|
|
148
|
+
const result = await runDialogs(request.questions ?? [], ctx, dialogTimeoutFor(request))
|
|
149
|
+
bus.emit(RESPONSE, { requestId: request.requestId, ...result })
|
|
150
|
+
})
|
|
151
|
+
.catch((err) => {
|
|
152
|
+
bus.emit(RESPONSE, {
|
|
153
|
+
requestId: request.requestId,
|
|
154
|
+
status: 'error',
|
|
155
|
+
answers: [],
|
|
156
|
+
error: String(err?.message ?? err),
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Agent side: the tool the LLM calls.
|
|
162
|
+
pi.registerTool({
|
|
163
|
+
name: 'ask_user_via_host',
|
|
164
|
+
label: 'Ask the user (checkpoint bridge)',
|
|
165
|
+
description:
|
|
166
|
+
"Ask the human user one or more questions and wait for their answers. The questions are relayed to the main session's UI by the checkpoint-bridge extension; batch related questions into one call. Use this for genuine ambiguities that would materially change what you produce — never for information you can obtain from files or the openspec CLI. Returns {\"status\": \"ok\"|\"timeout-or-cancelled\"|\"timeout\"|\"cancelled\"|\"no-host\"|\"error\", \"answers\": [{\"question\", \"answer\"}], \"note\"?.}",
|
|
167
|
+
promptSnippet: 'ask_user_via_host relays questions to the human user via the main session and waits for answers',
|
|
168
|
+
promptGuidelines: [
|
|
169
|
+
'Use ask_user_via_host when a requirement is ambiguous and the answer would materially change your output; batch all questions into a single call and keep each question answerable in one line.',
|
|
170
|
+
],
|
|
171
|
+
parameters: Type.Object({
|
|
172
|
+
questions: Type.Array(
|
|
173
|
+
Type.Object({
|
|
174
|
+
question: Type.String(),
|
|
175
|
+
options: Type.Optional(Type.Array(Type.String())),
|
|
176
|
+
placeholder: Type.Optional(Type.String()),
|
|
177
|
+
}),
|
|
178
|
+
{ minItems: 1 },
|
|
179
|
+
),
|
|
180
|
+
timeoutMs: Type.Optional(Type.Number()),
|
|
181
|
+
}),
|
|
182
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
183
|
+
const questions = params?.questions ?? []
|
|
184
|
+
const timeoutMs = Math.max(5_000, params?.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
185
|
+
const dialogTimeoutMs = Math.max(5_000, Math.min(DIALOG_TIMEOUT_MS, timeoutMs))
|
|
186
|
+
|
|
187
|
+
const noHost = {
|
|
188
|
+
status: 'no-host',
|
|
189
|
+
answers: [],
|
|
190
|
+
note: 'No main session has the checkpoint bridge armed (headless run, or host session gone). Fall back to a structured needs_input return instead of guessing.',
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// This instance IS the host (the main session's own model called the
|
|
194
|
+
// tool): answer locally instead of looping through the bus.
|
|
195
|
+
if (hostSessionId() === state.sessionId && state.ctx?.hasUI) {
|
|
196
|
+
return { content: [{ type: 'text', text: JSON.stringify(await runDialogs(questions, state.ctx, dialogTimeoutMs)) }] }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// No armed host at all (headless run, or the host session is gone).
|
|
200
|
+
if (!hostSessionId() || hostSessionId() === state.sessionId) {
|
|
201
|
+
return { content: [{ type: 'text', text: JSON.stringify(noHost) }] }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Relay to the host instance over the shared bus.
|
|
205
|
+
const requestId = `${NS}:${process.pid}:${requestCounter++}`
|
|
206
|
+
const result = await new Promise((resolve) => {
|
|
207
|
+
const cleanup = () => {
|
|
208
|
+
clearTimeout(timer)
|
|
209
|
+
if (signal && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort)
|
|
210
|
+
off(RESPONSE, onResponse)
|
|
211
|
+
}
|
|
212
|
+
const finish = (value) => {
|
|
213
|
+
cleanup()
|
|
214
|
+
resolve(value)
|
|
215
|
+
}
|
|
216
|
+
const timer = setTimeout(() => {
|
|
217
|
+
finish({
|
|
218
|
+
status: 'timeout',
|
|
219
|
+
answers: [],
|
|
220
|
+
note: `The user did not answer within ${Math.round(timeoutMs / 1000)}s. Fall back to a structured needs_input return instead of guessing.`,
|
|
221
|
+
})
|
|
222
|
+
}, timeoutMs)
|
|
223
|
+
const onAbort = () => finish({ status: 'cancelled', answers: [], note: 'Tool call aborted.' })
|
|
224
|
+
const onResponse = (response) => {
|
|
225
|
+
if (!response || response.requestId !== requestId) return
|
|
226
|
+
finish(response)
|
|
227
|
+
}
|
|
228
|
+
if (signal) {
|
|
229
|
+
if (signal.aborted) return finish({ status: 'cancelled', answers: [], note: 'Tool call aborted.' })
|
|
230
|
+
if (typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort)
|
|
231
|
+
}
|
|
232
|
+
bus.on(RESPONSE, onResponse)
|
|
233
|
+
bus.emit(REQUEST, {
|
|
234
|
+
requestId,
|
|
235
|
+
questions,
|
|
236
|
+
dialogTimeoutMs,
|
|
237
|
+
from: sessionRef(ctx),
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-checkpoint-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: lets sub-agent sessions ask the human user questions through the main session's UI — the checkpoint channel for the openspec* workflow family (grill rounds, apply blockers, any user-interaction checkpoint).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/raphaelbahat/pi-workflows.git",
|
|
10
|
+
"directory": "extensions/checkpoint-bridge"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi",
|
|
14
|
+
"pi-extension",
|
|
15
|
+
"subagents",
|
|
16
|
+
"checkpoint",
|
|
17
|
+
"human-in-the-loop",
|
|
18
|
+
"openspec"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"index.js",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"typebox": "^1.3.28"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./index.js"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|