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.
- package/README.md +447 -570
- package/extensions/privateer-brand.ts +9 -9
- package/package.json +1 -1
- package/src/cli/chat.ts +22 -10
- package/src/config/whatsNew.json +3 -3
- package/src/mcp/catalog.ts +479 -0
- package/src/remote/liveTaskSession.ts +12 -1
- package/src/remote/remoteBridge.ts +83 -22
- package/src/ui/palette.ts +17 -0
|
@@ -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
|
-
|
|
184
|
-
private readonly
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
300
|
-
if (resolve) resolve(value);
|
|
322
|
+
this.pendingSelects.get(id)?.resolve(value);
|
|
301
323
|
},
|
|
302
324
|
onInputResponse: (id, value) => {
|
|
303
|
-
|
|
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.
|
|
428
|
-
//
|
|
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
|
|
450
|
-
// abort,
|
|
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
|
-
//
|
|
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
|
|