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.
Files changed (3) hide show
  1. package/README.md +69 -0
  2. package/index.js +243 -0
  3. 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
+ }