@danypops/papyrus 0.27.9 → 0.27.10

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
@@ -943,7 +922,7 @@ class AskComponent extends Container {
943
922
  if (this.singleSelectList) return this.singleSelectList;
944
923
  const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.allowComment, this.theme, this.keybindings, this.shortcuts.commentToggle);
945
924
  list.onSubmit = (result) => this.handleSelectionSubmit([result], list.isCommentEnabled());
946
- list.onCancel = () => { diag("single-select onCancel fired (tui.select.cancel keybinding matched)"); this.onDone(null); };
925
+ list.onCancel = () => this.onDone(null);
947
926
  list.onEnterFreeform = () => this.showFreeformMode();
948
927
  this.singleSelectList = list;
949
928
  return list;
@@ -1099,9 +1078,7 @@ async function askViaDialogs(
1099
1078
 
1100
1079
  const selectOptions = options.map((o) => o.title);
1101
1080
  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
1081
  const selected = (await ui.select(prompt, selectOptions, dialogOpts)) as string | undefined;
1104
- diag("askViaDialogs: ui.select resolved", { selected: selected ?? null });
1105
1082
  if (isCancelledInput(selected)) return null;
1106
1083
 
1107
1084
  if (selected === FREEFORM_SENTINEL) {
@@ -1149,11 +1126,10 @@ const pendingByKey = new Map<string, Promise<AskAnswer | undefined>>();
1149
1126
  * contexts all resolve to undefined.
1150
1127
  */
1151
1128
  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
1129
  if (!ctx.hasUI || !ctx.ui) return undefined;
1154
1130
  if (params.key !== undefined) {
1155
1131
  const existing = pendingByKey.get(params.key);
1156
- if (existing) { diag("joining an already in-flight ask for this key", { key: params.key }); return existing; }
1132
+ if (existing) return existing;
1157
1133
  }
1158
1134
  const promise = askQuestionUnguarded(ctx, params);
1159
1135
  if (params.key !== undefined) {
@@ -1166,6 +1142,23 @@ export async function askQuestion(ctx: ExtensionContext, params: AskQuestionPara
1166
1142
  return promise;
1167
1143
  }
1168
1144
 
1145
+ /**
1146
+ * Sent at least this often while blocked on the human -- well under any 8-second window a
1147
+ * watchdog upstream might require to consider a tool call still alive. A single upfront ping
1148
+ * (this view's original heartbeat) only covers the first ~8s; for genuinely long human response
1149
+ * times (the whole point of a live ask) it goes silent again after that, indistinguishable from
1150
+ * dead. A live-observed bug traced back to exactly this: cancellation always cited "idle timeout,
1151
+ * no interaction within 8000ms" regardless of whether the real wait was 5 seconds or 18 minutes
1152
+ * -- a fixed re-check window, not a measure of total elapsed time -- meaning periodic liveness,
1153
+ * not a one-time ping, is what's required here.
1154
+ */
1155
+ let liveAskHeartbeatIntervalMs = 4_000;
1156
+
1157
+ /** Test seam: real tiny interval, not a faked global timer -- exercises the periodic heartbeat deterministically and fast. */
1158
+ export function setLiveAskHeartbeatIntervalMsForTests(ms: number): void {
1159
+ liveAskHeartbeatIntervalMs = ms;
1160
+ }
1161
+
1169
1162
  async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
1170
1163
  const options = params.options ?? [];
1171
1164
  const allowMultiple = params.allowMultiple ?? false;
@@ -1176,12 +1169,15 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
1176
1169
  const displayMode: AskDisplayMode = params.displayMode ?? envDisplayMode ?? "overlay";
1177
1170
  const normalizedContext = params.context?.trim() || undefined;
1178
1171
 
1179
- params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
1172
+ const sendHeartbeat = () => params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
1173
+ sendHeartbeat();
1174
+ const heartbeatTimer = params.onUpdate ? setInterval(sendHeartbeat, liveAskHeartbeatIntervalMs) : undefined;
1180
1175
  livePendingCount += 1;
1181
1176
  try {
1182
1177
  return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, displayMode, normalizedContext);
1183
1178
  } finally {
1184
1179
  livePendingCount -= 1;
1180
+ if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer);
1185
1181
  }
1186
1182
  }
1187
1183
 
@@ -1212,15 +1208,9 @@ async function askQuestionBlocking(
1212
1208
  let hasAnnouncedHide = false;
1213
1209
  let response: AskResponse | null;
1214
1210
  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);
1211
+ const factory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskResponse | null) => void) => {
1212
+ if (params.signal) params.signal.addEventListener("abort", () => done(null), { once: true });
1213
+ if (params.timeout && params.timeout > 0) setTimeout(() => done(null), params.timeout);
1224
1214
  return new AskComponent(params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, displayMode, tui, theme, keybindings, shortcuts, done);
1225
1215
  };
1226
1216
 
@@ -1235,12 +1225,8 @@ async function askQuestionBlocking(
1235
1225
  });
1236
1226
  }
1237
1227
 
1238
- diag("calling ctx.ui.custom()", { displayMode });
1239
1228
  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
1229
  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
1230
  } finally {
1245
1231
  removeOverlayInputListener?.();
1246
1232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.27.9",
3
+ "version": "0.27.10",
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"],