@danypops/papyrus 0.27.2 → 0.27.4

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.
@@ -35,6 +35,19 @@ import {
35
35
  wrapTextWithAnsi,
36
36
  } from "@earendil-works/pi-tui";
37
37
  import { renderSingleSelectRows, type AskOption } from "./discuss-ask-layout.ts";
38
+ import { callService } from "./service-client.ts";
39
+
40
+ /** Temporary RCA instrumentation for the live-observed duplicate-picker defect. Fire-and-forget: never lets a logging failure affect the actual ask. Remove once root-caused. */
41
+ const DIAG_SOURCE = "papyrus-discuss-ask-diag";
42
+ function diag(message: string, fields?: Record<string, unknown>): void {
43
+ void callService("logs.append", {
44
+ source_id: DIAG_SOURCE,
45
+ source_label: "Discuss live-ask RCA",
46
+ level: "info",
47
+ message,
48
+ ...(fields ? { fields } : {}),
49
+ }).catch(() => {});
50
+ }
38
51
 
39
52
  /** See pi-ask-user's identical safeMarkdownTheme() comment: a broken theme Proxy throws only on
40
53
  * property access, not construction, so a bare try/catch around getMarkdownTheme() alone would
@@ -62,6 +75,12 @@ export interface AskQuestionParams {
62
75
  allowComment?: boolean;
63
76
  displayMode?: AskDisplayMode;
64
77
  timeout?: number;
78
+ /**
79
+ * Joins a second concurrent call for the same key to the first's in-flight promise instead of
80
+ * opening a second picker. Pass a stable id (the target Discussion's id) whenever the caller
81
+ * cannot otherwise guarantee only one live ask is ever issued for that same question.
82
+ */
83
+ key?: string;
65
84
  /**
66
85
  * Streamed once before blocking on the human. A live ask can legitimately sit pending far
67
86
  * longer than a typical tool call (real human response time, not milliseconds) -- without any
@@ -1060,7 +1079,9 @@ async function askViaDialogs(
1060
1079
 
1061
1080
  const selectOptions = options.map((o) => o.title);
1062
1081
  if (allowFreeform) selectOptions.push(FREEFORM_SENTINEL);
1082
+ diag("askViaDialogs: calling ui.select", { dialogOpts: dialogOpts === undefined ? null : JSON.stringify(dialogOpts), promptPreview: prompt.slice(0, 80) });
1063
1083
  const selected = (await ui.select(prompt, selectOptions, dialogOpts)) as string | undefined;
1084
+ diag("askViaDialogs: ui.select resolved", { selected: selected ?? null });
1064
1085
  if (isCancelledInput(selected)) return null;
1065
1086
 
1066
1087
  if (selected === FREEFORM_SENTINEL) {
@@ -1091,6 +1112,16 @@ export function isLiveAskPending(): boolean {
1091
1112
  return livePendingCount > 0;
1092
1113
  }
1093
1114
 
1115
+ /**
1116
+ * Keyed reentrancy join: whatever the exact external cause (an upstream retry, a duplicate turn,
1117
+ * anything outside code this package controls -- verified Pi's own ctx.ui.custom() is a clean,
1118
+ * single-shot, well-guarded call with no retry/timeout logic of its own), a second concurrent
1119
+ * askQuestion() call for the SAME key must never open a second picker for the same question. It
1120
+ * joins the already-in-flight promise instead. Keyed by the target Discussion's id (stable across
1121
+ * a genuine duplicate call, unlike a fresh toolCallId each retry might mint).
1122
+ */
1123
+ const pendingByKey = new Map<string, Promise<AskAnswer | undefined>>();
1124
+
1094
1125
  /**
1095
1126
  * Discuss's live:true synchronous ask -- interactive AskComponent when a real TUI is available,
1096
1127
  * dialog fallback (ctx.ui.select/input) in RPC/headless mode, no-op undefined without any
@@ -1098,8 +1129,24 @@ export function isLiveAskPending(): boolean {
1098
1129
  * contexts all resolve to undefined.
1099
1130
  */
1100
1131
  export async function askQuestion(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
1132
+ diag("askQuestion() called", { question: params.question, key: params.key, hasUI: ctx.hasUI, mode: ctx.mode, optionCount: params.options?.length ?? 0, alreadyPendingForKey: params.key !== undefined && pendingByKey.has(params.key) });
1101
1133
  if (!ctx.hasUI || !ctx.ui) return undefined;
1134
+ if (params.key !== undefined) {
1135
+ const existing = pendingByKey.get(params.key);
1136
+ if (existing) { diag("joining an already in-flight ask for this key", { key: params.key }); return existing; }
1137
+ }
1138
+ const promise = askQuestionUnguarded(ctx, params);
1139
+ if (params.key !== undefined) {
1140
+ const key = params.key;
1141
+ pendingByKey.set(key, promise);
1142
+ void promise.finally(() => {
1143
+ if (pendingByKey.get(key) === promise) pendingByKey.delete(key);
1144
+ });
1145
+ }
1146
+ return promise;
1147
+ }
1102
1148
 
1149
+ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
1103
1150
  const options = params.options ?? [];
1104
1151
  const allowMultiple = params.allowMultiple ?? false;
1105
1152
  const allowFreeform = params.allowFreeform ?? true;
@@ -1162,8 +1209,12 @@ async function askQuestionBlocking(
1162
1209
  });
1163
1210
  }
1164
1211
 
1212
+ diag("calling ctx.ui.custom()", { displayMode });
1165
1213
  const customResult = await ctx.ui.custom<AskResponse | null>(factory, buildCustomUIOptions(displayMode, (handle) => { overlayHandle = handle; }));
1214
+ diag("ctx.ui.custom() resolved", { wasUndefined: customResult === undefined, result: customResult === undefined ? undefined : JSON.stringify(customResult) });
1215
+ if (customResult === undefined) diag("falling back to askViaDialogs -- about to call ctx.ui.select with dialogOpts", { explicitTimeout: params.timeout ?? null });
1166
1216
  response = customResult !== undefined ? customResult : await askViaDialogs(ctx.ui, params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, params.timeout);
1217
+ diag("askQuestionBlocking resolved", { response: response === null ? null : JSON.stringify(response) });
1167
1218
  } finally {
1168
1219
  removeOverlayInputListener?.();
1169
1220
  }
@@ -84,8 +84,8 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
84
84
  const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
85
85
  const question = `Reply to "${discussion.title}":`;
86
86
  const answer = pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
87
- ? await askQuestion(commandCtx, { question, options: pending.pendingOptions.map((title) => ({ title })), allowMultiple: pending.pendingOptionsMode === "multi" })
88
- : await askQuestion(commandCtx, { question });
87
+ ? await askQuestion(commandCtx, { question, options: pending.pendingOptions.map((title) => ({ title })), allowMultiple: pending.pendingOptionsMode === "multi", key: discussion.id })
88
+ : await askQuestion(commandCtx, { question, key: discussion.id });
89
89
  if (!answer) return; // canceled
90
90
  await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: SOURCE });
91
91
  commandCtx.ui.notify(answer.selected ? `Selected: ${answer.selected.join(", ")}` : "Reply added.", "info");
@@ -49,9 +49,10 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, onUpdate:
49
49
  options: pending.pendingOptions.map((title) => ({ title })),
50
50
  allowMultiple: pending.pendingOptionsMode === "multi",
51
51
  onUpdate,
52
+ key: discussion.id,
52
53
  });
53
54
  }
54
- return askQuestion(ctx, { question, onUpdate });
55
+ return askQuestion(ctx, { question, onUpdate, key: discussion.id });
55
56
  }
56
57
 
57
58
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.27.2",
3
+ "version": "0.27.4",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],