@danypops/papyrus 0.27.9 → 0.27.11

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,27 +35,6 @@ 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
- let diagSequence = 0;
43
- function diag(message: string, fields?: Record<string, unknown>): void {
44
- void callService("logs.append", {
45
- source_id: DIAG_SOURCE,
46
- source_label: "Discuss live-ask RCA",
47
- level: "info",
48
- message,
49
- operation_id: `diag-${Date.now()}-${++diagSequence}`,
50
- ...(fields ? { fields } : {}),
51
- }).catch((error) => {
52
- void callService("logs.append", {
53
- source_id: DIAG_SOURCE, source_label: "Discuss live-ask RCA", level: "error",
54
- message: `diag() itself failed: ${error instanceof Error ? error.message : String(error)}`,
55
- operation_id: `diag-error-${Date.now()}-${++diagSequence}`,
56
- }).catch(() => {});
57
- });
58
- }
59
38
 
60
39
  /** See pi-ask-user's identical safeMarkdownTheme() comment: a broken theme Proxy throws only on
61
40
  * property access, not construction, so a bare try/catch around getMarkdownTheme() alone would
@@ -84,19 +63,10 @@ export interface AskQuestionParams {
84
63
  displayMode?: AskDisplayMode;
85
64
  timeout?: number;
86
65
  /**
87
- * Joins a second concurrent call for the same key to the first's in-flight promise instead of
88
- * opening a second picker. Pass a stable id (the target Discussion's id) whenever the caller
89
- * cannot otherwise guarantee only one live ask is ever issued for that same question.
90
- */
91
- key?: string;
92
- /**
93
- * Streamed once before blocking on the human. A live ask can legitimately sit pending far
94
- * longer than a typical tool call (real human response time, not milliseconds) -- without any
95
- * progress signal, a tool call sitting silent that long looks indistinguishable from a dead
96
- * one to anything upstream watching for stalled calls. pi-ask-user's own original code (the
97
- * prior art this view is adapted from) sent exactly this same heartbeat before presenting its
98
- * UI; dropping it during the port was the regression that let two independent executions of
99
- * the same live ask run concurrently, each opening its own picker for the same question.
66
+ * Streamed once before blocking on the human, matching pi-ask-user's own original code (the
67
+ * prior art this view is adapted from) -- gives the tool call's progress UI something to show
68
+ * during a wait that legitimately runs far longer than a typical tool call (real human response
69
+ * time, not milliseconds).
100
70
  */
101
71
  onUpdate?: AgentToolUpdateCallback;
102
72
  /**
@@ -943,7 +913,7 @@ class AskComponent extends Container {
943
913
  if (this.singleSelectList) return this.singleSelectList;
944
914
  const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.allowComment, this.theme, this.keybindings, this.shortcuts.commentToggle);
945
915
  list.onSubmit = (result) => this.handleSelectionSubmit([result], list.isCommentEnabled());
946
- list.onCancel = () => { diag("single-select onCancel fired (tui.select.cancel keybinding matched)"); this.onDone(null); };
916
+ list.onCancel = () => this.onDone(null);
947
917
  list.onEnterFreeform = () => this.showFreeformMode();
948
918
  this.singleSelectList = list;
949
919
  return list;
@@ -1099,9 +1069,7 @@ async function askViaDialogs(
1099
1069
 
1100
1070
  const selectOptions = options.map((o) => o.title);
1101
1071
  if (allowFreeform) selectOptions.push(FREEFORM_SENTINEL);
1102
- diag("askViaDialogs: calling ui.select", { dialogOpts: dialogOpts === undefined ? null : JSON.stringify(dialogOpts), promptPreview: prompt.slice(0, 80) });
1103
1072
  const selected = (await ui.select(prompt, selectOptions, dialogOpts)) as string | undefined;
1104
- diag("askViaDialogs: ui.select resolved", { selected: selected ?? null });
1105
1073
  if (isCancelledInput(selected)) return null;
1106
1074
 
1107
1075
  if (selected === FREEFORM_SENTINEL) {
@@ -1122,9 +1090,8 @@ async function askViaDialogs(
1122
1090
  * driver (extension/src/index.ts's driveActiveTasks, on agent_settled) queue a "continue the
1123
1091
  * active task" nudge as a `deliverAs: "nextTurn"` message while this exact live ask is still
1124
1092
  * awaiting an answer -- starting a second, concurrent turn that reasons about the very Discussion
1125
- * this call is already resolving, independently of it. A live-observed bug (two pickers for the
1126
- * same question, one orphaned and later auto-resolving with fabricated "defer" text) traced back
1127
- * to exactly this race. driveActiveTasks checks isLiveAskPending() and skips queuing while true.
1093
+ * this call is already resolving, independently of it. driveActiveTasks checks isLiveAskPending()
1094
+ * and skips queuing while true.
1128
1095
  */
1129
1096
  let livePendingCount = 0;
1130
1097
 
@@ -1132,16 +1099,6 @@ export function isLiveAskPending(): boolean {
1132
1099
  return livePendingCount > 0;
1133
1100
  }
1134
1101
 
1135
- /**
1136
- * Keyed reentrancy join: whatever the exact external cause (an upstream retry, a duplicate turn,
1137
- * anything outside code this package controls -- verified Pi's own ctx.ui.custom() is a clean,
1138
- * single-shot, well-guarded call with no retry/timeout logic of its own), a second concurrent
1139
- * askQuestion() call for the SAME key must never open a second picker for the same question. It
1140
- * joins the already-in-flight promise instead. Keyed by the target Discussion's id (stable across
1141
- * a genuine duplicate call, unlike a fresh toolCallId each retry might mint).
1142
- */
1143
- const pendingByKey = new Map<string, Promise<AskAnswer | undefined>>();
1144
-
1145
1102
  /**
1146
1103
  * Discuss's live:true synchronous ask -- interactive AskComponent when a real TUI is available,
1147
1104
  * dialog fallback (ctx.ui.select/input) in RPC/headless mode, no-op undefined without any
@@ -1149,21 +1106,8 @@ const pendingByKey = new Map<string, Promise<AskAnswer | undefined>>();
1149
1106
  * contexts all resolve to undefined.
1150
1107
  */
1151
1108
  export async function askQuestion(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
1152
- 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) });
1153
1109
  if (!ctx.hasUI || !ctx.ui) return undefined;
1154
- if (params.key !== undefined) {
1155
- const existing = pendingByKey.get(params.key);
1156
- if (existing) { diag("joining an already in-flight ask for this key", { key: params.key }); return existing; }
1157
- }
1158
- const promise = askQuestionUnguarded(ctx, params);
1159
- if (params.key !== undefined) {
1160
- const key = params.key;
1161
- pendingByKey.set(key, promise);
1162
- void promise.finally(() => {
1163
- if (pendingByKey.get(key) === promise) pendingByKey.delete(key);
1164
- });
1165
- }
1166
- return promise;
1110
+ return askQuestionUnguarded(ctx, params);
1167
1111
  }
1168
1112
 
1169
1113
  async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
@@ -1212,15 +1156,9 @@ async function askQuestionBlocking(
1212
1156
  let hasAnnouncedHide = false;
1213
1157
  let response: AskResponse | null;
1214
1158
  try {
1215
- diag("factory about to construct AskComponent", { hasSignal: params.signal !== undefined, signalAlreadyAborted: params.signal?.aborted ?? null, hasExplicitTimeout: params.timeout !== undefined, allowMultiple });
1216
- const factory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, realDone: (result: AskResponse | null) => void) => {
1217
- const startedAt = Date.now();
1218
- const done = (result: AskResponse | null) => {
1219
- diag("done() invoked -- generic catch-all", { elapsedMs: Date.now() - startedAt, result: result === null ? null : JSON.stringify(result), stack: new Error().stack?.split("\n").slice(1, 6).join(" | ") });
1220
- realDone(result);
1221
- };
1222
- if (params.signal) params.signal.addEventListener("abort", () => { diag("tool call signal fired abort -- resolving null"); done(null); }, { once: true });
1223
- if (params.timeout && params.timeout > 0) setTimeout(() => { diag("explicit params.timeout expired -- resolving null", { timeout: params.timeout }); done(null); }, params.timeout);
1159
+ const factory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskResponse | null) => void) => {
1160
+ if (params.signal) params.signal.addEventListener("abort", () => done(null), { once: true });
1161
+ if (params.timeout && params.timeout > 0) setTimeout(() => done(null), params.timeout);
1224
1162
  return new AskComponent(params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, displayMode, tui, theme, keybindings, shortcuts, done);
1225
1163
  };
1226
1164
 
@@ -1235,12 +1173,8 @@ async function askQuestionBlocking(
1235
1173
  });
1236
1174
  }
1237
1175
 
1238
- diag("calling ctx.ui.custom()", { displayMode });
1239
1176
  const customResult = await ctx.ui.custom<AskResponse | null>(factory, buildCustomUIOptions(displayMode, (handle) => { overlayHandle = handle; }));
1240
- diag("ctx.ui.custom() resolved", { wasUndefined: customResult === undefined, result: customResult === undefined ? undefined : JSON.stringify(customResult) });
1241
- if (customResult === undefined) diag("falling back to askViaDialogs -- about to call ctx.ui.select with dialogOpts", { explicitTimeout: params.timeout ?? null });
1242
1177
  response = customResult !== undefined ? customResult : await askViaDialogs(ctx.ui, params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, params.timeout);
1243
- diag("askQuestionBlocking resolved", { response: response === null ? null : JSON.stringify(response) });
1244
1178
  } finally {
1245
1179
  removeOverlayInputListener?.();
1246
1180
  }
@@ -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", key: discussion.id })
88
- : await askQuestion(commandCtx, { question, key: discussion.id });
87
+ ? await askQuestion(commandCtx, { question, options: pending.pendingOptions.map((title) => ({ title })), allowMultiple: pending.pendingOptionsMode === "multi" })
88
+ : await askQuestion(commandCtx, { question });
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");
@@ -50,10 +50,9 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, onUpdate:
50
50
  allowMultiple: pending.pendingOptionsMode === "multi",
51
51
  onUpdate,
52
52
  signal,
53
- key: discussion.id,
54
53
  });
55
54
  }
56
- return askQuestion(ctx, { question, onUpdate, signal, key: discussion.id });
55
+ return askQuestion(ctx, { question, onUpdate, signal });
57
56
  }
58
57
 
59
58
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.27.9",
3
+ "version": "0.27.11",
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"],