pi-code 1.0.77 → 1.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.
@@ -4,8 +4,11 @@
4
4
  * only), or space-toggled checkboxes when `multiSelect` is set. An optional `header`
5
5
  * labels the question. Escape in the editor returns to options; Escape in options cancels.
6
6
  * Multiple questions per call are not batched; ask sequentially.
7
+ * On the TUI each question is also offered to a remote responder over pi.events
8
+ * (REMOTE_QUESTION_CHANNEL); the first answer, local or remote, wins.
7
9
  */
8
10
 
11
+ import { randomUUID } from 'node:crypto'
9
12
  import * as os from 'node:os'
10
13
  import * as path from 'node:path'
11
14
  import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
@@ -64,6 +67,26 @@ export interface QuestionSpec {
64
67
  multiSelect?: boolean
65
68
  }
66
69
 
70
+ /** In-process, question-owned offer; arbitrary custom TUI components are not sent. */
71
+ export const REMOTE_QUESTION_CHANNEL = 'pi-code:question:v1'
72
+ export type RemoteQuestionOutcome = { action: 'answer'; indices: number[] } | { action: 'text'; text: string } | { action: 'cancel' } | { action: 'pass' }
73
+
74
+ export interface RemoteQuestionOffer {
75
+ version: 1
76
+ requestId: string
77
+ sessionId: string
78
+ question: string
79
+ header?: string
80
+ options: OptionWithDesc[]
81
+ multiSelect: boolean
82
+ allowFreeText: boolean
83
+ signal: AbortSignal
84
+ /** Must be called during event emission; only one listener receives a settle handle. */
85
+ claim: () => ((outcome: RemoteQuestionOutcome) => boolean) | undefined
86
+ /** The claimed responder reports input activity to reset a configured idle timer. */
87
+ touch: () => boolean
88
+ }
89
+
67
90
  /** Normalize either accepted shape into the list of questions to ask. */
68
91
  function questionList(params: Partial<QuestionSpec> & { questions?: QuestionSpec[] }): QuestionSpec[] {
69
92
  if (params.questions && params.questions.length > 0) return params.questions
@@ -194,19 +217,19 @@ export default function question(pi: ExtensionAPI) {
194
217
  // parallel leave the first unanswerable and the run unable to finish.
195
218
  executionMode: 'sequential',
196
219
 
197
- async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
220
+ async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
198
221
  const specs = questionList(rawParams as Partial<QuestionSpec> & { questions?: QuestionSpec[] })
199
222
  if (specs.length === 0) {
200
223
  return { content: [{ type: 'text', text: 'Error: No question provided' }], details: { question: '', options: [], answer: null } as QuestionDetails }
201
224
  }
202
- if (specs.length === 1) return await askOne(specs[0], ctx)
225
+ if (specs.length === 1) return await askOne(specs[0], ctx, pi.events, signal)
203
226
 
204
227
  // Several questions are asked in sequence; a cancel ends the run, since the
205
228
  // remaining answers would be guesses about a flow the user just declined.
206
229
  const texts: string[] = []
207
230
  const collected: QuestionDetails[] = []
208
231
  for (const spec of specs) {
209
- const result = await askOne(spec, ctx)
232
+ const result = await askOne(spec, ctx, pi.events, signal)
210
233
  const detail = result.details as QuestionDetails
211
234
  collected.push(detail)
212
235
  texts.push(`${spec.question}\n${result.content[0].text}`)
@@ -258,7 +281,135 @@ export default function question(pi: ExtensionAPI) {
258
281
  })
259
282
  }
260
283
 
261
- async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ content: Array<{ type: 'text'; text: string }>; details: QuestionDetails }> {
284
+ type QuestionAnswer = Awaited<ReturnType<typeof askViaOverlay>>
285
+
286
+ /** A claimed offer raced against the open overlay: whichever answers first wins. */
287
+ interface RemoteQuestion {
288
+ /** Routes remote answers into the overlay and remote activity into its idle timer. */
289
+ attach: (answer: (value: QuestionAnswer) => void, touch: () => void) => void
290
+ /** Ends the offer; the responder's settle handle and touch are rejected from then on. */
291
+ close: () => void
292
+ }
293
+ type RemoteOffer = { kind: 'settled'; value: QuestionAnswer } | { kind: 'claimed'; remote: RemoteQuestion }
294
+
295
+ function validIndices(indices: unknown, count: number, multiSelect: boolean): indices is number[] {
296
+ if (!Array.isArray(indices) || (!multiSelect && indices.length !== 1)) return false
297
+ return new Set(indices).size === indices.length && indices.every((index) => Number.isInteger(index) && index >= 1 && index <= count)
298
+ }
299
+
300
+ /** A remote outcome as the answer it stands for: null is a cancel, undefined is a
301
+ * pass or anything this question cannot accept, which must never become an answer. */
302
+ function remoteAnswer(outcome: RemoteQuestionOutcome, options: DisplayOption[], multiSelect: boolean): QuestionAnswer | undefined {
303
+ if (outcome === null || typeof outcome !== 'object') return undefined
304
+ if (outcome.action === 'cancel') return null
305
+ if (outcome.action === 'text') {
306
+ const text = typeof outcome.text === 'string' ? outcome.text.trim() : ''
307
+ return !multiSelect && text ? { answer: text, wasCustom: true } : undefined
308
+ }
309
+ if (outcome.action !== 'answer') return undefined
310
+ // Read once and copied, so a getter or Proxy cannot answer with other indices
311
+ // than the ones that passed validation.
312
+ const raw: unknown = outcome.indices
313
+ const indices: unknown = Array.isArray(raw) ? [...raw] : undefined
314
+ if (!validIndices(indices, options.length, multiSelect)) return undefined
315
+ const checked = options.map((_, index) => indices.includes(index + 1))
316
+ return { answer: selectedLabels(options, checked), wasCustom: false, ...(multiSelect ? {} : { index: indices[0] }) }
317
+ }
318
+
319
+ /** Undefined when nobody claims synchronously, so the overlay opens with no async gap. */
320
+ function offerRemoteQuestion(params: QuestionSpec, allOptions: DisplayOption[], ctx: ExtensionContext, events: ExtensionAPI['events'] | undefined, signal: AbortSignal | undefined): RemoteOffer | undefined {
321
+ if (!events) return undefined
322
+ if (signal?.aborted) return { kind: 'settled', value: null }
323
+
324
+ const multiSelect = params.multiSelect === true
325
+ const controller = new AbortController()
326
+ let claimed = false
327
+ let early: { value: QuestionAnswer } | undefined
328
+ let deliver: ((value: QuestionAnswer) => void) | undefined
329
+ let touchOverlay: (() => void) | undefined
330
+ const close = (): void => {
331
+ controller.abort()
332
+ signal?.removeEventListener('abort', abort)
333
+ }
334
+ const settle = (value: QuestionAnswer): void => {
335
+ close()
336
+ if (deliver) deliver(value)
337
+ else early = { value }
338
+ }
339
+ // Pi aborts the turn before a session replacement invalidates ctx, so this is
340
+ // also the session-replacement path; ctx is never read after emission.
341
+ const abort = (): void => settle(null)
342
+ signal?.addEventListener('abort', abort, { once: true })
343
+
344
+ const offer: RemoteQuestionOffer = {
345
+ version: 1,
346
+ requestId: randomUUID(),
347
+ sessionId: ctx.sessionManager.getSessionId(),
348
+ question: params.question,
349
+ header: shortHeader(params.header),
350
+ options: params.options.map(({ label, description }) => ({ label, ...(description === undefined ? {} : { description }) })),
351
+ multiSelect,
352
+ allowFreeText: allOptions.some((option) => option.isOther === true),
353
+ signal: controller.signal,
354
+ claim: () => {
355
+ if (claimed || controller.signal.aborted) return undefined
356
+ claimed = true
357
+ return (outcome) => {
358
+ if (controller.signal.aborted) return false
359
+ const value = remoteAnswer(outcome, params.options, multiSelect)
360
+ if (value !== undefined) {
361
+ settle(value)
362
+ return true
363
+ }
364
+ // A pass or invalid reply only withdraws the remote side; the overlay stays
365
+ // open, and a turn abort must still cancel it.
366
+ controller.abort()
367
+ return outcome?.action === 'pass'
368
+ }
369
+ },
370
+ touch: () => {
371
+ if (!claimed || controller.signal.aborted) return false
372
+ touchOverlay?.()
373
+ return true
374
+ },
375
+ }
376
+
377
+ events.emit(REMOTE_QUESTION_CHANNEL, offer)
378
+ if (early) return { kind: 'settled', value: early.value }
379
+ if (!claimed || controller.signal.aborted) {
380
+ close()
381
+ return undefined
382
+ }
383
+ return {
384
+ kind: 'claimed',
385
+ remote: {
386
+ attach: (answer, touch) => {
387
+ deliver = answer
388
+ touchOverlay = touch
389
+ },
390
+ close,
391
+ },
392
+ }
393
+ }
394
+
395
+ /** ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
396
+ * undefined immediately, which would read as a cancel without ever asking. Ask
397
+ * through the dialog primitives there instead. askUserQuestionTimeout is a TUI
398
+ * concept (a countdown, a keypress resetting it): the dialog-primitive fallback
399
+ * has no keyboard or visible countdown to drive it, so it is not applied there. */
400
+ async function collectAnswer(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, events?: ExtensionAPI['events'], signal?: AbortSignal): Promise<QuestionAnswer> {
401
+ if (ctx.mode !== 'tui') return askViaDialogs(params, ctx, allOptions, multiSelect)
402
+ const offer = offerRemoteQuestion(params, allOptions, ctx, events, signal)
403
+ if (offer?.kind === 'settled') return offer.value
404
+ const remote = offer?.remote
405
+ try {
406
+ return await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs(), remote)
407
+ } finally {
408
+ remote?.close()
409
+ }
410
+ }
411
+
412
+ async function askOne(params: QuestionSpec, ctx: ExtensionContext, events?: ExtensionAPI['events'], signal?: AbortSignal): Promise<{ content: Array<{ type: 'text'; text: string }>; details: QuestionDetails }> {
262
413
  if (!ctx.hasUI) {
263
414
  return {
264
415
  content: [{ type: 'text', text: 'Error: UI not available (running in non-interactive mode)' }],
@@ -281,12 +432,7 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
281
432
  // The free-text option does not compose with checkbox selection, so it is single-select only.
282
433
  const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
283
434
 
284
- // ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
285
- // undefined immediately, which would read as a cancel without ever asking. Ask
286
- // through the dialog primitives there instead. askUserQuestionTimeout is a TUI
287
- // concept (a countdown, a keypress resetting it): the dialog-primitive fallback
288
- // has no keyboard or visible countdown to drive it, so it is not applied there.
289
- const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs()) : await askViaDialogs(params, ctx, allOptions, multiSelect)
435
+ const result = await collectAnswer(params, ctx, allOptions, multiSelect, events, signal)
290
436
 
291
437
  // Build simple options list for details; header/multiSelect appear only when set,
292
438
  // so single-select details are unchanged.
@@ -345,7 +491,7 @@ const IDLE_TICK_MS = 250
345
491
  * mechanics are testable directly, independent of where timeoutMs itself is read
346
492
  * from (askUserQuestionTimeoutMs, tested separately).
347
493
  */
348
- export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
494
+ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number, remote?: RemoteQuestion): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
349
495
  return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null) => void) => {
350
496
  let optionIndex = 0
351
497
  let editMode = false
@@ -368,6 +514,8 @@ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOp
368
514
  * here, so the interval can never outlive the overlay it belongs to. */
369
515
  function finish(value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null): void {
370
516
  stopIdleTimer()
517
+ // Closed before done, so a remote reply racing a local answer is rejected.
518
+ remote?.close()
371
519
  done(value)
372
520
  }
373
521
 
@@ -376,6 +524,8 @@ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOp
376
524
  deadline = Date.now() + timeoutMs
377
525
  }
378
526
 
527
+ remote?.attach(finish, resetIdleTimer)
528
+
379
529
  function fireTimeout(): void {
380
530
  // Claude: "submits any options you'd already selected". Single-select has
381
531
  // nothing pre-committed (a selection only exists once Enter confirms it), so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.77",
3
+ "version": "1.1.0",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",