@nklisch/pi-enhanced 0.2.0 → 0.2.2

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/node_modules/@nklisch/pi-background-tasks/CHANGELOG.md +7 -0
  3. package/node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.test.ts +105 -0
  4. package/node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.ts +131 -44
  5. package/node_modules/@nklisch/pi-background-tasks/package.json +1 -1
  6. package/node_modules/@nklisch/pi-background-tasks/skills/background-tasks/SKILL.md +6 -7
  7. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  8. package/node_modules/@nklisch/pi-clearance/package.json +1 -1
  9. package/node_modules/@nklisch/pi-clearance/src/config/config-command-writer.ts +34 -17
  10. package/node_modules/@nklisch/pi-clearance/src/index.ts +34 -3
  11. package/node_modules/@nklisch/pi-clearance/src/packs/package-registration.ts +63 -36
  12. package/node_modules/@nklisch/pi-clearance/src/runtime/allow-request-message.ts +40 -1
  13. package/node_modules/@nklisch/pi-clearance/src/runtime/command-registry.ts +22 -2
  14. package/node_modules/@nklisch/pi-clearance/src/runtime/command-transforms.ts +50 -29
  15. package/node_modules/@nklisch/pi-clearance/src/runtime/config-commands/settings/native-ui.ts +233 -49
  16. package/node_modules/@nklisch/pi-clearance/src/runtime/policy-cache.ts +19 -5
  17. package/node_modules/@nklisch/pi-conveniences/extensions/context-window-footer.ts +48 -13
  18. package/node_modules/@nklisch/pi-conveniences/package.json +1 -1
  19. package/node_modules/@nklisch/pi-fff-compat/extensions/fff-compat-search.ts +49 -42
  20. package/node_modules/@nklisch/pi-fff-compat/extensions/finder-lifecycle.ts +92 -0
  21. package/node_modules/@nklisch/pi-fff-compat/package.json +2 -2
  22. package/node_modules/@nklisch/pi-model-modes/package.json +1 -1
  23. package/node_modules/@nklisch/pi-model-modes/src/command-message.ts +30 -0
  24. package/node_modules/@nklisch/pi-model-modes/src/commands.ts +31 -15
  25. package/node_modules/@nklisch/pi-model-modes/src/style-command.ts +25 -14
  26. package/node_modules/@nklisch/pi-plugins/README.md +2 -2
  27. package/node_modules/@nklisch/pi-plugins/dist/application/subagent-hook-runtime.js +4 -1
  28. package/node_modules/@nklisch/pi-plugins/dist/application/subagent-hook-runtime.js.map +1 -1
  29. package/node_modules/@nklisch/pi-plugins/dist/composition/background-update-coordinator.js +12 -3
  30. package/node_modules/@nklisch/pi-plugins/dist/composition/background-update-coordinator.js.map +1 -1
  31. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.d.ts +1 -0
  32. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.js +19 -11
  33. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.js.map +1 -1
  34. package/node_modules/@nklisch/pi-plugins/dist/pi/hooks/pi-hook-decision-adapter.js +36 -7
  35. package/node_modules/@nklisch/pi-plugins/dist/pi/hooks/pi-hook-decision-adapter.js.map +1 -1
  36. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-component.js +14 -3
  37. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-component.js.map +1 -1
  38. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-session.js +153 -54
  39. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-session.js.map +1 -1
  40. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-lifecycle.js +35 -8
  41. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-lifecycle.js.map +1 -1
  42. package/node_modules/@nklisch/pi-plugins/dist/runtime/mcp/pi-mcp-adapter-package.js +1 -1
  43. package/node_modules/@nklisch/pi-plugins/dist/runtime/mcp/pi-mcp-adapter-runtime.js +1 -1
  44. package/node_modules/@nklisch/pi-plugins/dist/runtime/subagents/pi-subagents-package.js +1 -1
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +7 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +22 -17
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/debug.ts +18 -0
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +22 -4
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +32 -8
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +8 -7
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +25 -6
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +6 -2
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +31 -16
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +70 -32
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +55 -50
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace-bracket.ts +6 -2
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/record-observer.ts +37 -34
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +15 -14
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +31 -26
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +51 -26
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +100 -46
  63. package/node_modules/@nklisch/pi-plugins/package.json +3 -3
  64. package/package.json +4 -4
  65. package/test/verify-bundle.mjs +8 -0
@@ -152,51 +152,72 @@ export function createPackageRegistrationStore(
152
152
  }
153
153
 
154
154
  function notify(): void {
155
- onChange?.();
155
+ try {
156
+ onChange?.();
157
+ } catch (error) {
158
+ // The change hook invalidates another in-memory subsystem. It is part of
159
+ // this callback's recovery path, so a broken hook must remain observable
160
+ // without escaping into the host event bus.
161
+ recordListenerIssue("change-notification-error", error);
162
+ }
163
+ }
164
+
165
+ function recordListenerIssue(code: string, error: unknown): void {
166
+ const message = `package registration event handler failed: ${errorMessage(error)}`;
167
+ issues.push({ severity: "error", code, path: "event", message });
168
+ console.error(`Pi Clearance ${message}`);
156
169
  }
157
170
 
158
171
  // Register-event listener. Classifies each incoming payload by request id:
159
172
  // accept unsolicited (no requestId) and current-request responses; warn and
160
- // ignore stale responses for older request ids. The normalizer is total, so
161
- // this handler never throws on payload shape. The unsubscribe handle is kept
162
- // so `session_shutdown` can detach this store from Pi's persistent event bus.
173
+ // ignore stale responses for older request ids. The event bus is an
174
+ // out-of-band callback boundary: payload property getters and normalizers
175
+ // operate inside the containment block so hostile contributor input cannot
176
+ // escape through Pi's shared event bus.
163
177
  const unsubscribeRegister = bus.on(
164
178
  AUTO_REVIEWER_PACKS_REGISTER_EVENT,
165
179
  (data: unknown) => {
166
- if (disposed) {
167
- return;
168
- }
180
+ try {
181
+ if (disposed) {
182
+ return;
183
+ }
184
+
185
+ const rawRequestId = readRequestId(data);
169
186
 
170
- const rawRequestId = readRequestId(data);
187
+ if (rawRequestId !== undefined && rawRequestId !== currentRequestId) {
188
+ // Stale: a response for a request we are no longer servicing (e.g. an
189
+ // older request id after reload). Record a warning and contribute
190
+ // nothing, so stale data can never widen available packs.
191
+ issues.push({
192
+ severity: "warning",
193
+ code: "stale-registration",
194
+ path: "event",
195
+ message: buildStaleMessage(rawRequestId, currentRequestId),
196
+ });
197
+ if (!isCollecting) {
198
+ notify();
199
+ }
200
+ return;
201
+ }
171
202
 
172
- if (rawRequestId !== undefined && rawRequestId !== currentRequestId) {
173
- // Stale: a response for a request we are no longer servicing (e.g. an
174
- // older request id after reload). Record a warning and contribute
175
- // nothing, so stale data can never widen available packs.
176
- issues.push({
177
- severity: "warning",
178
- code: "stale-registration",
179
- path: "event",
180
- message: buildStaleMessage(rawRequestId, currentRequestId),
181
- });
203
+ // Accept: matches the current request id, or unsolicited (no request
204
+ // id). Unsolicited registrations support manual tests and contributors
205
+ // that emit during their own session_start.
206
+ const result = normalizePackagePackRegistration(data);
207
+ for (const pack of result.packs) {
208
+ packs.push(pack);
209
+ }
210
+ for (const issue of result.issues) {
211
+ issues.push(issue);
212
+ }
213
+ if (!isCollecting) {
214
+ notify();
215
+ }
216
+ } catch (error) {
217
+ recordListenerIssue("listener-error", error);
182
218
  if (!isCollecting) {
183
219
  notify();
184
220
  }
185
- return;
186
- }
187
-
188
- // Accept: matches the current request id, or unsolicited (no request id).
189
- // Unsolicited registrations support manual tests and contributors that
190
- // emit during their own session_start.
191
- const result = normalizePackagePackRegistration(data);
192
- for (const pack of result.packs) {
193
- packs.push(pack);
194
- }
195
- for (const issue of result.issues) {
196
- issues.push(issue);
197
- }
198
- if (!isCollecting) {
199
- notify();
200
221
  }
201
222
  },
202
223
  );
@@ -233,9 +254,7 @@ export function createPackageRegistrationStore(
233
254
  severity: "error",
234
255
  code: "collection-error",
235
256
  path: "event",
236
- message: `package registration request failed: ${
237
- error instanceof Error ? error.message : String(error)
238
- }`,
257
+ message: `package registration request failed: ${errorMessage(error)}`,
239
258
  });
240
259
  } finally {
241
260
  isCollecting = false;
@@ -297,3 +316,11 @@ function buildStaleMessage(
297
316
  ? `Ignoring package registration for request id "${rawRequestId}" (no active collection).`
298
317
  : `Ignoring package registration for stale request id "${rawRequestId}" (current "${currentRequestId}").`;
299
318
  }
319
+
320
+ function errorMessage(error: unknown): string {
321
+ try {
322
+ return error instanceof Error ? error.message : String(error);
323
+ } catch {
324
+ return "unknown error";
325
+ }
326
+ }
@@ -16,6 +16,45 @@ export interface ClearanceAllowRequestDetails {
16
16
  export const renderClearanceAllowRequest: MessageRenderer<
17
17
  ClearanceAllowRequestDetails
18
18
  > = (message) => {
19
- const form = message.details?.form ?? "request";
19
+ const form = readAllowRequestForm(message);
20
20
  return new Text(`[Pi Clearance] Allow request handed to agent (${form})`);
21
21
  };
22
+
23
+ function readAllowRequestForm(message: unknown):
24
+ | ClearanceAllowRequestDetails["form"]
25
+ | "request" {
26
+ try {
27
+ if (!isRecord(message)) return "request";
28
+ const details = message.details;
29
+ if (!isRecord(details)) return "request";
30
+ const form = details.form;
31
+ return isAllowRequestForm(form) ? form : "request";
32
+ } catch (error) {
33
+ // Message details come from the persisted/custom-message boundary. A
34
+ // hostile getter must degrade the transcript row, not escape the renderer.
35
+ console.error(
36
+ `Pi Clearance allow-request details validation failed: ${errorMessage(error)}`,
37
+ );
38
+ return "request";
39
+ }
40
+ }
41
+
42
+ function isAllowRequestForm(value: unknown): value is ClearanceAllowRequestDetails["form"] {
43
+ return (
44
+ value === "free-text" ||
45
+ value === "recent-command" ||
46
+ value === "no-recent-command"
47
+ );
48
+ }
49
+
50
+ function isRecord(value: unknown): value is Record<string, unknown> {
51
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
+ }
53
+
54
+ function errorMessage(error: unknown): string {
55
+ try {
56
+ return error instanceof Error ? error.message : String(error);
57
+ } catch {
58
+ return "unknown error";
59
+ }
60
+ }
@@ -87,8 +87,20 @@ export function buildClearanceCommandOptions(
87
87
  return getClearanceArgumentCompletions(argumentPrefix, deps);
88
88
  },
89
89
  async handler(args, ctx) {
90
- const report = await handleClearanceCommand(args, ctx, pi, deps);
91
- notify(ctx, report.markdown, report.level ?? "info");
90
+ try {
91
+ const report = await handleClearanceCommand(args, ctx, pi, deps);
92
+ notify(ctx, report.markdown, report.level ?? "info");
93
+ } catch (error) {
94
+ // Pi catches registered command promises, but that host-level catch does
95
+ // not provide a useful slash-command recovery message. Do not convert
96
+ // this into a write result or attempt rollback: any completed durable
97
+ // write remains completed, and the user gets an observable diagnostic.
98
+ notify(
99
+ ctx,
100
+ `Pi Clearance command failed: ${errorMessage(error)}`,
101
+ "error",
102
+ );
103
+ }
92
104
  },
93
105
  };
94
106
  }
@@ -364,3 +376,11 @@ function splitCompletionPrefix(argumentPrefix: string): {
364
376
  current: tokens.at(-1) ?? "",
365
377
  };
366
378
  }
379
+
380
+ function errorMessage(error: unknown): string {
381
+ try {
382
+ return error instanceof Error ? error.message : String(error);
383
+ } catch {
384
+ return "unknown error";
385
+ }
386
+ }
@@ -170,36 +170,51 @@ export function createCommandTransformStore(
170
170
  currentRequestId = null;
171
171
  }
172
172
 
173
+ function recordListenerIssue(code: string, error: unknown): void {
174
+ const message = `command transform registration event handler failed: ${errorMessage(error)}`;
175
+ issues.push({ severity: "error", code, path: "event", message });
176
+ console.error(`Pi Clearance ${message}`);
177
+ }
178
+
173
179
  const unsubscribeRegister = on(
174
180
  AUTO_REVIEWER_TRANSFORMS_REGISTER_EVENT,
175
181
  (data: unknown) => {
176
- if (disposed) {
177
- return;
178
- }
182
+ try {
183
+ if (disposed) {
184
+ return;
185
+ }
179
186
 
180
- const rawRequestId = readRequestId(data);
181
- if (rawRequestId !== undefined && rawRequestId !== currentRequestId) {
182
- // Stale response for a request we are no longer servicing. Ignore it so
183
- // stale data can never widen the transform set.
184
- issues.push({
185
- severity: "warning",
186
- code: "stale-registration",
187
- path: "event",
188
- message: buildStaleMessage(rawRequestId, currentRequestId),
189
- });
190
- return;
191
- }
187
+ // This is a raw event-bus callback. Keep request-id getters,
188
+ // normalization, and array updates in one containment block: a
189
+ // contributor can supply a Proxy/getter rather than a plain object.
190
+ const rawRequestId = readRequestId(data);
191
+ if (rawRequestId !== undefined && rawRequestId !== currentRequestId) {
192
+ // Stale response for a request we are no longer servicing. Ignore it
193
+ // so stale data can never widen the transform set.
194
+ issues.push({
195
+ severity: "warning",
196
+ code: "stale-registration",
197
+ path: "event",
198
+ message: buildStaleMessage(rawRequestId, currentRequestId),
199
+ });
200
+ return;
201
+ }
192
202
 
193
- const result = normalizeCommandTransformRegistration(data);
194
- if (result.transform !== null) {
195
- transforms.push({
196
- id: result.transform.id,
197
- description: result.transform.description,
198
- run: result.transform.transform,
199
- });
200
- }
201
- for (const issue of result.issues) {
202
- issues.push(issue);
203
+ const result = normalizeCommandTransformRegistration(data);
204
+ if (result.transform !== null) {
205
+ transforms.push({
206
+ id: result.transform.id,
207
+ description: result.transform.description,
208
+ run: result.transform.transform,
209
+ });
210
+ }
211
+ for (const issue of result.issues) {
212
+ issues.push(issue);
213
+ }
214
+ } catch (error) {
215
+ // Do not rely on Pi's event bus swallowing errors. This listener is an
216
+ // extension-owned out-of-band boundary and must be total on its own.
217
+ recordListenerIssue("listener-error", error);
203
218
  }
204
219
  },
205
220
  );
@@ -227,9 +242,7 @@ export function createCommandTransformStore(
227
242
  severity: "error",
228
243
  code: "collection-error",
229
244
  path: "event",
230
- message: `transform registration request failed: ${
231
- error instanceof Error ? error.message : String(error)
232
- }`,
245
+ message: `transform registration request failed: ${errorMessage(error)}`,
233
246
  });
234
247
  }
235
248
 
@@ -310,7 +323,7 @@ export function createCommandTransformStore(
310
323
  lastNote = {
311
324
  id: transform.id,
312
325
  kind: "error",
313
- message: error instanceof Error ? error.message : String(error),
326
+ message: errorMessage(error),
314
327
  };
315
328
  // fail open: keep current command
316
329
  }
@@ -371,3 +384,11 @@ function buildStaleMessage(
371
384
  ? `Ignoring transform registration for request id "${rawRequestId}" (no active collection).`
372
385
  : `Ignoring transform registration for stale request id "${rawRequestId}" (current "${currentRequestId}").`;
373
386
  }
387
+
388
+ function errorMessage(error: unknown): string {
389
+ try {
390
+ return error instanceof Error ? error.message : String(error);
391
+ } catch {
392
+ return "unknown error";
393
+ }
394
+ }
@@ -146,10 +146,14 @@ interface MinimalTui {
146
146
  const DOSSIER_VISIBLE_LINES = 12;
147
147
 
148
148
  export function canOpenSettingsNativeUi(ctx: ExtensionCommandContext): boolean {
149
- const custom = (
150
- ctx as unknown as { readonly ui?: { readonly custom?: unknown } }
151
- ).ui?.custom;
152
- return ctx.hasUI === true && typeof custom === "function";
149
+ try {
150
+ const custom = (
151
+ ctx as unknown as { readonly ui?: { readonly custom?: unknown } }
152
+ ).ui?.custom;
153
+ return ctx.hasUI === true && typeof custom === "function";
154
+ } catch {
155
+ return false;
156
+ }
153
157
  }
154
158
 
155
159
  export async function openSettingsNativeUi(
@@ -179,15 +183,27 @@ export async function openSettingsNativeUi(
179
183
  const handled: SettingsNativeUiHandledAction[] = [];
180
184
 
181
185
  while (true) {
182
- const interaction = await presentSettingsNativeUi({
183
- ctx: options.ctx,
184
- model,
185
- screen,
186
- selected,
187
- dossierScroll,
188
- dossierOrigin,
189
- ...(message === undefined ? {} : { message }),
190
- });
186
+ let interaction: NativeSettingsUiInteraction;
187
+ try {
188
+ interaction = await presentSettingsNativeUi({
189
+ ctx: options.ctx,
190
+ model,
191
+ screen,
192
+ selected,
193
+ dossierScroll,
194
+ dossierOrigin,
195
+ ...(message === undefined ? {} : { message }),
196
+ });
197
+ } catch (error) {
198
+ return {
199
+ ok: false,
200
+ report: nativeSettingsFailureReport(
201
+ "presentation",
202
+ error,
203
+ handled.length,
204
+ ),
205
+ };
206
+ }
191
207
  const selection = interaction.selection;
192
208
  if (selection.kind === "close") {
193
209
  return {
@@ -201,11 +217,16 @@ export async function openSettingsNativeUi(
201
217
  };
202
218
  }
203
219
 
204
- const report = await dispatchNativeSettingsSelection(
205
- selection,
206
- options.ctx,
207
- options.deps,
208
- );
220
+ let report: CommandReport;
221
+ try {
222
+ report = await dispatchNativeSettingsSelection(
223
+ selection,
224
+ options.ctx,
225
+ options.deps,
226
+ );
227
+ } catch (error) {
228
+ report = nativeSettingsFailureReport("action", error, handled.length);
229
+ }
209
230
  const handledAction: SettingsNativeUiHandledAction = {
210
231
  label: selection.label,
211
232
  reportTitle: report.title,
@@ -232,7 +253,15 @@ export async function openSettingsNativeUi(
232
253
  selected = dossier === undefined ? interaction.selected : 0;
233
254
  dossierScroll = dossier === undefined ? interaction.dossierScroll : 0;
234
255
 
235
- const reloaded = await options.reload();
256
+ let reloaded: Awaited<ReturnType<SettingsNativeUiReload>>;
257
+ try {
258
+ reloaded = await options.reload();
259
+ } catch (error) {
260
+ return {
261
+ ok: false,
262
+ report: nativeSettingsFailureReport("reload", error, handled.length),
263
+ };
264
+ }
236
265
  if (!reloaded.ok) {
237
266
  return { ok: false, report: reloaded.report };
238
267
  }
@@ -252,25 +281,86 @@ async function presentSettingsNativeUi(input: {
252
281
  const custom = input.ctx.ui.custom.bind(input.ctx.ui);
253
282
  return await custom<NativeSettingsUiInteraction>(
254
283
  (tui, theme, _keybindings, done) => {
255
- let component!: SettingsNativeUiComponent;
256
- component = new SettingsNativeUiComponent({
257
- model: input.model,
258
- theme: theme as MinimalTheme,
259
- done: (selection) =>
260
- done({ selection, ...component.getNavigationState() }),
261
- initialScreen: input.screen,
262
- initialSelected: input.selected,
263
- initialDossierScroll: input.dossierScroll,
264
- dossierOrigin: input.dossierOrigin,
265
- ...(input.message === undefined ? {} : { message: input.message }),
266
- });
284
+ let component: SettingsNativeUiComponent | undefined;
285
+ let lastFailure: string | undefined;
286
+ const fallbackNavigation = {
287
+ screen: input.screen,
288
+ selected: input.selected,
289
+ dossierScroll: input.dossierScroll,
290
+ } satisfies Omit<NativeSettingsUiInteraction, "selection">;
291
+
292
+ const reportFailure = (phase: string, error: unknown): void => {
293
+ const diagnostic = `Pi Clearance native settings ${phase} failed: ${errorMessage(error)}`;
294
+ if (diagnostic === lastFailure) return;
295
+ lastFailure = diagnostic;
296
+ console.error(diagnostic);
297
+ };
298
+
299
+ const safeDone = (selection: SettingsNativeUiSelection): void => {
300
+ try {
301
+ done({
302
+ selection,
303
+ ...(component?.getNavigationState() ?? fallbackNavigation),
304
+ });
305
+ } catch (error) {
306
+ reportFailure("completion", error);
307
+ }
308
+ };
309
+
310
+ try {
311
+ component = new SettingsNativeUiComponent({
312
+ model: input.model,
313
+ theme: theme as MinimalTheme,
314
+ done: safeDone,
315
+ initialScreen: input.screen,
316
+ initialSelected: input.selected,
317
+ initialDossierScroll: input.dossierScroll,
318
+ dossierOrigin: input.dossierOrigin,
319
+ ...(input.message === undefined ? {} : { message: input.message }),
320
+ });
321
+ } catch (error) {
322
+ reportFailure("component construction", error);
323
+ }
324
+
267
325
  const minimalTui = tui as MinimalTui;
268
326
  return {
269
- render: (width: number) => component.render(width),
270
- invalidate: () => component.invalidate(),
327
+ render: (width: number) => {
328
+ try {
329
+ if (component === undefined) {
330
+ return renderNativeSettingsFallback(width, lastFailure);
331
+ }
332
+ return component.render(width);
333
+ } catch (error) {
334
+ reportFailure("render", error);
335
+ return renderNativeSettingsFallback(width, lastFailure);
336
+ }
337
+ },
338
+ invalidate: () => {
339
+ try {
340
+ component?.invalidate();
341
+ } catch (error) {
342
+ reportFailure("invalidation", error);
343
+ }
344
+ },
271
345
  handleInput: (data: string) => {
272
- component.handleInput(data);
273
- minimalTui.requestRender?.();
346
+ try {
347
+ if (component === undefined) {
348
+ if (isClose(data)) safeDone({ kind: "close" });
349
+ } else {
350
+ component.handleInput(data);
351
+ }
352
+ } catch (error) {
353
+ reportFailure("input", error);
354
+ }
355
+
356
+ // requestRender is a host callback too; a broken TUI refresh must
357
+ // not prevent the component from accepting a later close/recovery
358
+ // input or turn a completed settings write into a failed command.
359
+ try {
360
+ minimalTui.requestRender?.call(tui);
361
+ } catch (error) {
362
+ reportFailure("input refresh", error);
363
+ }
274
364
  },
275
365
  };
276
366
  },
@@ -320,16 +410,29 @@ async function readNativeSettingsInput(
320
410
  | { readonly ok: true; readonly value: string | null }
321
411
  | { readonly ok: false; readonly report: CommandReport }
322
412
  > {
323
- const input = (
324
- ctx as unknown as {
325
- readonly ui?: {
326
- readonly input?: (
327
- title: string,
328
- placeholder?: string,
329
- ) => Promise<string | undefined> | string | undefined;
330
- };
331
- }
332
- ).ui?.input;
413
+ let input:
414
+ | ((
415
+ title: string,
416
+ placeholder?: string,
417
+ ) => Promise<string | undefined> | string | undefined)
418
+ | undefined;
419
+ try {
420
+ input = (
421
+ ctx as unknown as {
422
+ readonly ui?: {
423
+ readonly input?: (
424
+ title: string,
425
+ placeholder?: string,
426
+ ) => Promise<string | undefined> | string | undefined;
427
+ };
428
+ }
429
+ ).ui?.input;
430
+ } catch (error) {
431
+ return {
432
+ ok: false,
433
+ report: nativeInputRefusedReport(selection, "failed", error),
434
+ };
435
+ }
333
436
 
334
437
  if (typeof input !== "function") {
335
438
  return {
@@ -338,7 +441,29 @@ async function readNativeSettingsInput(
338
441
  };
339
442
  }
340
443
 
341
- const value = await input(selection.prompt, selection.placeholder ?? "");
444
+ let value: string | undefined;
445
+ try {
446
+ const rawValue = await input(
447
+ selection.prompt,
448
+ selection.placeholder ?? "",
449
+ );
450
+ if (rawValue !== undefined && typeof rawValue !== "string") {
451
+ return {
452
+ ok: false,
453
+ report: nativeInputRefusedReport(
454
+ selection,
455
+ "failed",
456
+ new Error("Pi UI input returned a non-string value"),
457
+ ),
458
+ };
459
+ }
460
+ value = rawValue;
461
+ } catch (error) {
462
+ return {
463
+ ok: false,
464
+ report: nativeInputRefusedReport(selection, "failed", error),
465
+ };
466
+ }
342
467
  if (value === undefined) {
343
468
  return {
344
469
  ok: false,
@@ -346,7 +471,15 @@ async function readNativeSettingsInput(
346
471
  };
347
472
  }
348
473
 
349
- const trimmed = value.trim();
474
+ let trimmed: string;
475
+ try {
476
+ trimmed = value.trim();
477
+ } catch (error) {
478
+ return {
479
+ ok: false,
480
+ report: nativeInputRefusedReport(selection, "failed", error),
481
+ };
482
+ }
350
483
  if (trimmed.length === 0) {
351
484
  if (selection.empty === "null") {
352
485
  return { ok: true, value: null };
@@ -365,7 +498,8 @@ function nativeInputRefusedReport(
365
498
  SettingsNativeUiSelection,
366
499
  { readonly kind: "input-action" }
367
500
  >,
368
- reason: "unavailable" | "cancelled" | "empty",
501
+ reason: "unavailable" | "cancelled" | "empty" | "failed",
502
+ error?: unknown,
369
503
  ): CommandReport {
370
504
  const summary = (() => {
371
505
  switch (reason) {
@@ -375,6 +509,8 @@ function nativeInputRefusedReport(
375
509
  return "Text input was cancelled; no config changes were written.";
376
510
  case "empty":
377
511
  return "Text input was empty; no config changes were written.";
512
+ case "failed":
513
+ return `Text input failed: ${errorMessage(error)}; no config changes were written.`;
378
514
  }
379
515
  })();
380
516
 
@@ -390,10 +526,50 @@ function nativeInputRefusedReport(
390
526
  "- No config changes were written.",
391
527
  ].join("\n"),
392
528
  details: { reason: `native-input-${reason}`, selection },
393
- level: reason === "unavailable" ? "error" : "warning",
529
+ level: reason === "unavailable" || reason === "failed" ? "error" : "warning",
530
+ };
531
+ }
532
+
533
+ function nativeSettingsFailureReport(
534
+ phase: string,
535
+ error: unknown,
536
+ actionsHandled: number,
537
+ ): CommandReport {
538
+ const message = errorMessage(error);
539
+ const summary = `Pi Clearance settings ${phase} failed: ${message}. Previously confirmed actions, if any, remain applied; no rollback was attempted.`;
540
+ return {
541
+ title: "Pi Clearance settings failed",
542
+ summary,
543
+ markdown: [
544
+ "# Pi Clearance settings failed",
545
+ "",
546
+ `- Phase: ${phase}`,
547
+ `- Error: ${message}`,
548
+ `- Actions already handled: ${actionsHandled}`,
549
+ "- Reopen status to verify any previously confirmed writes.",
550
+ ].join("\n"),
551
+ details: { reason: `native-ui-${phase}-failed`, actionsHandled, error: message },
552
+ level: "error",
394
553
  };
395
554
  }
396
555
 
556
+ function renderNativeSettingsFallback(
557
+ width: number,
558
+ diagnostic: string | undefined,
559
+ ): string[] {
560
+ const lines = [
561
+ "Pi Clearance settings UI encountered an error.",
562
+ diagnostic ?? "The settings component could not be rendered.",
563
+ "Previously confirmed actions, if any, remain applied.",
564
+ "Press q or Esc to close.",
565
+ ];
566
+ try {
567
+ return lines.map((line) => truncateAnsi(line, Math.max(1, width)));
568
+ } catch {
569
+ return ["Pi Clearance settings UI unavailable.", "Press q or Esc to close."];
570
+ }
571
+ }
572
+
397
573
  interface NativeSettingsMessage {
398
574
  readonly level: "info" | "warning" | "error";
399
575
  readonly text: string;
@@ -1045,3 +1221,11 @@ function isClose(data: string): boolean {
1045
1221
  export function truncateAnsi(input: string, width: number): string {
1046
1222
  return truncateToWidth(input, width, width >= 2 ? "…" : "");
1047
1223
  }
1224
+
1225
+ function errorMessage(error: unknown): string {
1226
+ try {
1227
+ return error instanceof Error ? error.message : String(error);
1228
+ } catch {
1229
+ return "unknown error";
1230
+ }
1231
+ }