@danypops/papyrus 0.27.1 → 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
@@ -1073,6 +1079,34 @@ async function askViaDialogs(
1073
1079
  return createSelectionResponse([selected], comment);
1074
1080
  }
1075
1081
 
1082
+ /**
1083
+ * Tracks whether a live ask is genuinely mid-flight, blocked on the human. `ExtensionContext.isIdle()`
1084
+ * means "not streaming a model response" -- it reads true while a slow, human-blocking tool call
1085
+ * like this one is still pending, since the model already finished emitting the tool_call and
1086
+ * is not itself generating anything. Left unguarded, that lets the active-task continuation
1087
+ * driver (extension/src/index.ts's driveActiveTasks, on agent_settled) queue a "continue the
1088
+ * active task" nudge as a `deliverAs: "nextTurn"` message while this exact live ask is still
1089
+ * awaiting an answer -- starting a second, concurrent turn that reasons about the very Discussion
1090
+ * this call is already resolving, independently of it. A live-observed bug (two pickers for the
1091
+ * same question, one orphaned and later auto-resolving with fabricated "defer" text) traced back
1092
+ * to exactly this race. driveActiveTasks checks isLiveAskPending() and skips queuing while true.
1093
+ */
1094
+ let livePendingCount = 0;
1095
+
1096
+ export function isLiveAskPending(): boolean {
1097
+ return livePendingCount > 0;
1098
+ }
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
+
1076
1110
  /**
1077
1111
  * Discuss's live:true synchronous ask -- interactive AskComponent when a real TUI is available,
1078
1112
  * dialog fallback (ctx.ui.select/input) in RPC/headless mode, no-op undefined without any
@@ -1081,7 +1115,22 @@ async function askViaDialogs(
1081
1115
  */
1082
1116
  export async function askQuestion(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
1083
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
+ }
1084
1132
 
1133
+ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
1085
1134
  const options = params.options ?? [];
1086
1135
  const allowMultiple = params.allowMultiple ?? false;
1087
1136
  const allowFreeform = params.allowFreeform ?? true;
@@ -1092,7 +1141,24 @@ export async function askQuestion(ctx: ExtensionContext, params: AskQuestionPara
1092
1141
  const normalizedContext = params.context?.trim() || undefined;
1093
1142
 
1094
1143
  params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
1144
+ livePendingCount += 1;
1145
+ try {
1146
+ return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, displayMode, normalizedContext);
1147
+ } finally {
1148
+ livePendingCount -= 1;
1149
+ }
1150
+ }
1095
1151
 
1152
+ async function askQuestionBlocking(
1153
+ ctx: ExtensionContext,
1154
+ params: AskQuestionParams,
1155
+ options: AskOption[],
1156
+ allowMultiple: boolean,
1157
+ allowFreeform: boolean,
1158
+ allowComment: boolean,
1159
+ displayMode: AskDisplayMode,
1160
+ normalizedContext: string | undefined,
1161
+ ): Promise<AskAnswer | undefined> {
1096
1162
  if (options.length === 0) {
1097
1163
  const prompt = normalizedContext ? `${params.question}\n\nContext:\n${normalizedContext}` : params.question;
1098
1164
  const answer = await ctx.ui.input(prompt, "Type your answer...", params.timeout ? { timeout: params.timeout } : undefined);
@@ -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
  /**
@@ -22,6 +22,7 @@ import type { GateResult } from "../../src/domain/gate.ts";
22
22
  import { formatMetadata } from "./artifact-format.ts";
23
23
  import { callService } from "./service-client.ts";
24
24
  import { registerDomainTools } from "./domain-tools.ts";
25
+ import { isLiveAskPending } from "./discuss-ask-view.ts";
25
26
  import { registerPlaybookBridge } from "./playbook-bridge.ts";
26
27
  import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
27
28
  import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
@@ -202,6 +203,11 @@ export default async function (pi: ExtensionAPI) {
202
203
 
203
204
  const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
204
205
  if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
206
+ // ctx.isIdle() means "not streaming a model response" -- it reads true while a live discuss
207
+ // ask is still genuinely pending, blocked on the human. Queuing a "continue the active task"
208
+ // nudge here would start a second, concurrent turn reasoning about the very Discussion this
209
+ // live ask is already resolving. See discuss-ask-view.ts's isLiveAskPending() doc comment.
210
+ if (isLiveAskPending()) return;
205
211
  try {
206
212
  const sessionId = ctx.sessionManager.getSessionId();
207
213
  const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd, session_id: sessionId });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.27.1",
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"],