privateer-agent 0.15.0 → 0.15.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.
@@ -50,6 +50,14 @@ const chartOpTimeoutMs = (): number => Number(process.env.PRIVATEER_CHART_TIMEOU
50
50
  // outcome — the model tells the user it failed while the file is in their library.
51
51
  const librarySaveTimeoutMs = (): number => Number(process.env.PRIVATEER_LIBRARY_TIMEOUT_MS) || 180_000;
52
52
 
53
+ // How long a question put to the app (a select or a text prompt) outlives the app going
54
+ // away. A phone that backgrounds, a tab switch, a socket blip — each detaches the
55
+ // controller for a moment, and failing the question there turned an ask_user_question
56
+ // into "the user declined" while the user was still reading it. The question is held
57
+ // and re-sent on reattach instead; this is only the bound on waiting for someone who
58
+ // isn't coming back, so a turn can't sit on a question forever.
59
+ const questionGraceMs = (): number => Number(process.env.PRIVATEER_QUESTION_GRACE_MS) || 10 * 60_000;
60
+
53
61
  // The outbound surface the bridge needs; RelayClient implements all of it.
54
62
  export interface RelayLike {
55
63
  requestApproval(id: string, req: PermissionRequest): void;
@@ -180,8 +188,11 @@ export class RemoteBridge {
180
188
  private remote = false;
181
189
  private noQuarter = noQuarterActive();
182
190
  private readonly pending = new Map<string, (d: AskOutcome) => void>();
183
- private readonly pendingSelects = new Map<string, (v: string | null) => void>();
184
- private readonly pendingInputs = new Map<string, (v: string | null) => void>();
191
+ // Open questions keep their request so a reattaching app can be sent them again.
192
+ private readonly pendingSelects = new Map<string, { resolve: (v: string | null) => void; req: SelectRequest }>();
193
+ private readonly pendingInputs = new Map<string, { resolve: (v: string | null) => void; req: InputRequest }>();
194
+ // Armed while the open questions have no app to answer them (see questionGraceMs).
195
+ private questionGrace?: ReturnType<typeof setTimeout>;
185
196
  private readonly pendingCargo = new Map<string, (r: CargoSaveResult) => void>();
186
197
  private readonly pendingCharts = new Map<string, (r: ChartOpResult) => void>();
187
198
  private readonly pendingLibrary = new Map<string, (r: LibrarySaveResult) => void>();
@@ -223,6 +234,8 @@ export class RemoteBridge {
223
234
  /** Release what outlives a socket. Safe to call twice. */
224
235
  dispose(): void {
225
236
  this.unwatchPrivacy();
237
+ if (this.questionGrace) clearTimeout(this.questionGrace);
238
+ this.questionGrace = undefined;
226
239
  this.relay = undefined;
227
240
  }
228
241
 
@@ -238,10 +251,20 @@ export class RemoteBridge {
238
251
  readonly callbacks: Required<RelayCallbacks> = {
239
252
  onPrompt: (text) => {
240
253
  this.remote = true; // a remote turn is now in flight → gate relays each action
241
- this.turnDriven = true;
242
- this.turnPrompt = text;
243
- this.turnText = "";
244
- this.turnTerminalSent = false;
254
+ // A message typed while a question is open IS the answer to it. The app shows the
255
+ // question as a sheet over the composer, and people answer by typing anyway — the
256
+ // turn is blocked on the question, so the text has nowhere else useful to go.
257
+ // Free text is a valid answer to both: ask_user_question's dialog walker reads a
258
+ // reply that matches no option as the user's own answer.
259
+ if (text.trim() && this.answerOpenQuestion(text)) return;
260
+ // A message sent into a turn already in flight steers it (the owner decides how);
261
+ // it must not restart the capture of the reply that turn is still writing.
262
+ if (!this.turnDriven) {
263
+ this.turnDriven = true;
264
+ this.turnPrompt = text;
265
+ this.turnText = "";
266
+ this.turnTerminalSent = false;
267
+ }
245
268
  const attachments = this.pendingAttachments;
246
269
  this.pendingAttachments = [];
247
270
  this.cfg.onPrompt(text, attachments);
@@ -296,12 +319,10 @@ export class RemoteBridge {
296
319
  if (resolve) resolve(decision);
297
320
  },
298
321
  onSelectResponse: (id, value) => {
299
- const resolve = this.pendingSelects.get(id);
300
- if (resolve) resolve(value);
322
+ this.pendingSelects.get(id)?.resolve(value);
301
323
  },
302
324
  onInputResponse: (id, value) => {
303
- const resolve = this.pendingInputs.get(id);
304
- if (resolve) resolve(value);
325
+ this.pendingInputs.get(id)?.resolve(value);
305
326
  },
306
327
  onCargoSaved: (id, result) => {
307
328
  const resolve = this.pendingCargo.get(id);
@@ -344,6 +365,10 @@ export class RemoteBridge {
344
365
  // earlier session of this app — an attaching controller must not paint a shield
345
366
  // over a terminal that has none. Sent only when OFF, so silence means protected.
346
367
  if (privacyDisabled()) this.relay?.sendPrivacy?.(true);
368
+ // The app drops its open question sheets on every reconnect and expects the
369
+ // terminal to say what is still being asked. Re-send under the SAME ids, so an
370
+ // answer to the earlier frame (one that was in flight across the blip) still lands.
371
+ this.resendOpenQuestions();
347
372
  this.cfg.onControllerAttached?.();
348
373
  },
349
374
  // The app left while we're still running. Same posture as a dropped socket: stop
@@ -354,6 +379,7 @@ export class RemoteBridge {
354
379
  onControllerDetached: () => {
355
380
  this.remote = false;
356
381
  this.rejectAllPending();
382
+ this.holdOpenQuestions();
357
383
  this.cfg.onControllerDetached?.();
358
384
  },
359
385
  onAttachment: (file) => {
@@ -365,6 +391,7 @@ export class RemoteBridge {
365
391
  this.remote = false;
366
392
  // Any approval waiting on a now-gone controller fails closed.
367
393
  this.rejectAllPending();
394
+ this.holdOpenQuestions();
368
395
  },
369
396
  };
370
397
 
@@ -424,8 +451,10 @@ export class RemoteBridge {
424
451
  }
425
452
 
426
453
  // A CLI-initiated selection prompt: relay the options to the app and await its
427
- // choice. Fail closed (null) if no controller, on abort, or on disconnect — the
428
- // same posture as remoteAsk. Callers get the chosen `value` or null.
454
+ // choice. Null if no controller when asked, or on abort. Unlike an approval, a
455
+ // question is NOT failed by the app going away: it is held, re-sent on reattach,
456
+ // and only gives up after questionGraceMs with nobody back (see holdOpenQuestions).
457
+ // Callers get the chosen `value` or null.
429
458
  selectRemote = (req: SelectRequest, signal?: AbortSignal): Promise<string | null> => {
430
459
  if (!this.relay || !this.relay.isConnected()) return Promise.resolve(null);
431
460
  const id = randomUUID();
@@ -434,9 +463,10 @@ export class RemoteBridge {
434
463
  const settle = (v: string | null) => {
435
464
  this.pendingSelects.delete(id);
436
465
  signal?.removeEventListener("abort", onAbort);
466
+ this.releaseQuestionGrace();
437
467
  resolve(v);
438
468
  };
439
- this.pendingSelects.set(id, settle);
469
+ this.pendingSelects.set(id, { resolve: settle, req });
440
470
  if (signal) {
441
471
  if (signal.aborted) return onAbort();
442
472
  signal.addEventListener("abort", onAbort, { once: true });
@@ -446,8 +476,8 @@ export class RemoteBridge {
446
476
  };
447
477
 
448
478
  // A CLI-initiated free-form text prompt: relay it to the app and await the typed
449
- // line. Same fail-closed posture as selectRemote — null if no controller, on
450
- // abort, or on disconnect. Callers get the submitted string or null.
479
+ // line. Same posture as selectRemote — null if no controller when asked or on
480
+ // abort, held across a detach. Callers get the submitted string or null.
451
481
  inputRemote = (req: InputRequest, signal?: AbortSignal): Promise<string | null> => {
452
482
  if (!this.relay || !this.relay.isConnected()) return Promise.resolve(null);
453
483
  const id = randomUUID();
@@ -456,9 +486,10 @@ export class RemoteBridge {
456
486
  const settle = (v: string | null) => {
457
487
  this.pendingInputs.delete(id);
458
488
  signal?.removeEventListener("abort", onAbort);
489
+ this.releaseQuestionGrace();
459
490
  resolve(v);
460
491
  };
461
- this.pendingInputs.set(id, settle);
492
+ this.pendingInputs.set(id, { resolve: settle, req });
462
493
  if (signal) {
463
494
  if (signal.aborted) return onAbort();
464
495
  signal.addEventListener("abort", onAbort, { once: true });
@@ -667,15 +698,45 @@ export class RemoteBridge {
667
698
  });
668
699
  };
669
700
 
701
+ /** Is a question (select or text prompt) waiting on the app? */
702
+ hasOpenQuestion(): boolean {
703
+ return this.pendingSelects.size > 0 || this.pendingInputs.size > 0;
704
+ }
705
+
706
+ // Answer the oldest open question with typed text. False when none is open.
707
+ private answerOpenQuestion(text: string): boolean {
708
+ const first = this.pendingSelects.values().next().value ?? this.pendingInputs.values().next().value;
709
+ if (!first) return false;
710
+ first.resolve(text);
711
+ return true;
712
+ }
713
+
714
+ private resendOpenQuestions(): void {
715
+ this.releaseQuestionGrace();
716
+ for (const [id, q] of this.pendingSelects) this.relay?.requestSelect(id, q.req);
717
+ for (const [id, q] of this.pendingInputs) this.relay?.requestInput(id, q.req);
718
+ }
719
+
720
+ // The app went away with questions open: keep them, but not forever.
721
+ private holdOpenQuestions(): void {
722
+ if (!this.hasOpenQuestion() || this.questionGrace) return;
723
+ this.questionGrace = setTimeout(() => {
724
+ this.questionGrace = undefined;
725
+ for (const q of [...this.pendingSelects.values(), ...this.pendingInputs.values()]) q.resolve(null);
726
+ }, questionGraceMs());
727
+ this.questionGrace.unref?.();
728
+ }
729
+
730
+ private releaseQuestionGrace(): void {
731
+ if (this.hasOpenQuestion() && this.questionGrace) return; // still waiting on the rest
732
+ if (this.questionGrace) clearTimeout(this.questionGrace);
733
+ this.questionGrace = undefined;
734
+ }
735
+
670
736
  private rejectAllPending(): void {
671
737
  for (const resolve of this.pending.values()) resolve("deny");
672
738
  this.pending.clear();
673
- // A relayed selection prompt whose controller vanished resolves to "no choice".
674
- for (const resolve of this.pendingSelects.values()) resolve(null);
675
- this.pendingSelects.clear();
676
- // Same for a relayed text prompt: a gone controller resolves to "no input".
677
- for (const resolve of this.pendingInputs.values()) resolve(null);
678
- this.pendingInputs.clear();
739
+ // Selects and text prompts are deliberately NOT failed here — see holdOpenQuestions.
679
740
  // A save whose controller vanished mid-flight is NOT reported as failed-and-done:
680
741
  // the app may have encrypted and stored the artifact before its socket dropped, and
681
742
  // the frame carrying the id is what we lost. Saying "it didn't save" would send the
package/src/ui/palette.ts CHANGED
@@ -39,6 +39,8 @@ export type Palette = {
39
39
  GREEN: string; // success (connected, verified, context loaded)
40
40
  YELLOW: string; // warning (not-signed-in, unconfirmed, update available)
41
41
  RED: string; // alarm (the moat lowered — no-quarter)
42
+ SKY: string; // light blue (the wordmark's ✻ and P)
43
+ WHITE: string; // bright white (the context-file ⚓)
42
44
  };
43
45
 
44
46
  // Last-resort palette for when no theme is reachable (headless surfaces have no banner,
@@ -54,6 +56,8 @@ export const FALLBACK: Palette = {
54
56
  GREEN: `${ESC}32m`,
55
57
  YELLOW: `${ESC}33m`,
56
58
  RED: `${ESC}31m`,
59
+ SKY: c(117),
60
+ WHITE: c(231),
57
61
  };
58
62
 
59
63
  // Build a Palette from a Pi Theme. getFgAnsi(name) returns the raw SGR foreground escape
@@ -79,9 +83,22 @@ export function paletteFor(theme: any): Palette {
79
83
  GREEN: g("success", FALLBACK.GREEN),
80
84
  YELLOW: g("warning", FALLBACK.YELLOW),
81
85
  RED: g("error", FALLBACK.RED),
86
+ // Fixed colours, not theme tokens — but a pale blue or white vanishes on a light
87
+ // background, so a light theme gets a deeper blue and plain ink instead.
88
+ ...(lightAppearance(theme)
89
+ ? { SKY: c(32), WHITE: g("text", FALLBACK.INK) }
90
+ : { SKY: FALLBACK.SKY, WHITE: FALLBACK.WHITE }),
82
91
  };
83
92
  }
84
93
 
94
+ function lightAppearance(theme: any): boolean {
95
+ try {
96
+ return theme.appearance === "light";
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
85
102
  // ── standalone REPL palette (no Pi Theme) ────────────────────────────────────
86
103
  export type TerminalScheme = "light" | "dark";
87
104