@danypops/papyrus 0.27.2 → 0.27.3

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.
@@ -62,6 +62,12 @@ export interface AskQuestionParams {
62
62
  allowComment?: boolean;
63
63
  displayMode?: AskDisplayMode;
64
64
  timeout?: number;
65
+ /**
66
+ * Joins a second concurrent call for the same key to the first's in-flight promise instead of
67
+ * opening a second picker. Pass a stable id (the target Discussion's id) whenever the caller
68
+ * cannot otherwise guarantee only one live ask is ever issued for that same question.
69
+ */
70
+ key?: string;
65
71
  /**
66
72
  * Streamed once before blocking on the human. A live ask can legitimately sit pending far
67
73
  * longer than a typical tool call (real human response time, not milliseconds) -- without any
@@ -1091,6 +1097,16 @@ export function isLiveAskPending(): boolean {
1091
1097
  return livePendingCount > 0;
1092
1098
  }
1093
1099
 
1100
+ /**
1101
+ * Keyed reentrancy join: whatever the exact external cause (an upstream retry, a duplicate turn,
1102
+ * anything outside code this package controls -- verified Pi's own ctx.ui.custom() is a clean,
1103
+ * single-shot, well-guarded call with no retry/timeout logic of its own), a second concurrent
1104
+ * askQuestion() call for the SAME key must never open a second picker for the same question. It
1105
+ * joins the already-in-flight promise instead. Keyed by the target Discussion's id (stable across
1106
+ * a genuine duplicate call, unlike a fresh toolCallId each retry might mint).
1107
+ */
1108
+ const pendingByKey = new Map<string, Promise<AskAnswer | undefined>>();
1109
+
1094
1110
  /**
1095
1111
  * Discuss's live:true synchronous ask -- interactive AskComponent when a real TUI is available,
1096
1112
  * dialog fallback (ctx.ui.select/input) in RPC/headless mode, no-op undefined without any
@@ -1099,7 +1115,22 @@ export function isLiveAskPending(): boolean {
1099
1115
  */
1100
1116
  export async function askQuestion(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
1101
1117
  if (!ctx.hasUI || !ctx.ui) return undefined;
1118
+ if (params.key !== undefined) {
1119
+ const existing = pendingByKey.get(params.key);
1120
+ if (existing) return existing;
1121
+ }
1122
+ const promise = askQuestionUnguarded(ctx, params);
1123
+ if (params.key !== undefined) {
1124
+ const key = params.key;
1125
+ pendingByKey.set(key, promise);
1126
+ void promise.finally(() => {
1127
+ if (pendingByKey.get(key) === promise) pendingByKey.delete(key);
1128
+ });
1129
+ }
1130
+ return promise;
1131
+ }
1102
1132
 
1133
+ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
1103
1134
  const options = params.options ?? [];
1104
1135
  const allowMultiple = params.allowMultiple ?? false;
1105
1136
  const allowFreeform = params.allowFreeform ?? true;
@@ -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.3",
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"],