privateer-agent 0.1.0 → 0.2.1

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 (44) hide show
  1. package/README.md +86 -33
  2. package/package.json +1 -1
  3. package/src/auth/privateer.ts +71 -1
  4. package/src/commands/custom.ts +52 -4
  5. package/src/commands/registry.ts +124 -5
  6. package/src/components/App.tsx +268 -18
  7. package/src/components/ApprovalPrompt.tsx +15 -4
  8. package/src/components/Banner.tsx +21 -1
  9. package/src/components/ModelPicker.tsx +45 -12
  10. package/src/components/OptionPicker.tsx +134 -0
  11. package/src/components/Root.tsx +30 -9
  12. package/src/components/StatusBar.tsx +11 -1
  13. package/src/components/ToolCallView.tsx +4 -0
  14. package/src/components/Transcript.tsx +14 -7
  15. package/src/components/figures.ts +1 -0
  16. package/src/components/theme.ts +2 -0
  17. package/src/config/paths.ts +2 -0
  18. package/src/context/systemPrompt.ts +9 -0
  19. package/src/daemon/index.ts +322 -0
  20. package/src/daemon/ipc.ts +127 -0
  21. package/src/engine/errors.ts +10 -0
  22. package/src/main.tsx +43 -1
  23. package/src/mcp/client.ts +16 -1
  24. package/src/permissions/gate.ts +5 -0
  25. package/src/permissions/mode.ts +4 -0
  26. package/src/permissions/uiGate.ts +4 -3
  27. package/src/remote/relayClient.ts +161 -6
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +75 -0
  30. package/src/routines/schema.ts +65 -0
  31. package/src/routines/store.ts +205 -0
  32. package/src/routines/toolSelect.ts +48 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +37 -12
  35. package/src/skills/installer.ts +222 -0
  36. package/src/skills/loader.ts +88 -0
  37. package/src/tools/askUser.ts +92 -0
  38. package/src/tools/context.ts +14 -0
  39. package/src/tools/index.ts +14 -0
  40. package/src/tools/routine.ts +110 -0
  41. package/src/tools/sendFileToClient.ts +55 -0
  42. package/src/tools/skill.ts +44 -0
  43. package/src/tools/worktree.ts +145 -0
  44. package/src/util/images.ts +35 -0
@@ -7,6 +7,7 @@ import { Banner } from "./Banner.tsx";
7
7
  import { StatusBar, formatTokens, formatDuration } from "./StatusBar.tsx";
8
8
  import { RowView, groupRows, visualRows, clampStreamingText } from "./Transcript.tsx";
9
9
  import { ApprovalPrompt } from "./ApprovalPrompt.tsx";
10
+ import { OptionPicker } from "./OptionPicker.tsx";
10
11
  import { ModelPicker } from "./ModelPicker.tsx";
11
12
  import { PromptInput } from "./PromptInput.tsx";
12
13
  import { PlanConfirm } from "./PlanConfirm.tsx";
@@ -25,7 +26,7 @@ import { loadMcpServers, connectMcpServers, type McpConnection } from "../mcp/cl
25
26
  import { hasStoredAuth, clearStoredAuth } from "../mcp/oauth.ts";
26
27
  import { TodoPanel } from "./TodoPanel.tsx";
27
28
  import { exec } from "../tools/exec.ts";
28
- import { resolveAttachments, chipFor } from "../util/images.ts";
29
+ import { resolveAttachments, chipFor, mediaModality } from "../util/images.ts";
29
30
  import type { Attachment } from "../util/images.ts";
30
31
  import { AttachmentStore } from "../util/attachmentStore.ts";
31
32
  import type { Entry, ToolEntry, Row } from "./types.ts";
@@ -35,13 +36,20 @@ import { createSession } from "../session.ts";
35
36
  import { QueryEngine } from "../engine/QueryEngine.ts";
36
37
  import { emptyUsage, type UsageTotals } from "../engine/events.ts";
37
38
  import { runCommand, commandList } from "../commands/registry.ts";
39
+ import { sendToDaemon, DaemonNotRunningError } from "../daemon/ipc.ts";
40
+ import { drainNotices } from "../routines/store.ts";
41
+ import { describeTrigger } from "../routines/trigger.ts";
42
+ import type { Routine } from "../routines/schema.ts";
38
43
  import { isSlashCommand } from "./promptModel.ts";
39
44
  import { loadCustomCommands } from "../commands/custom.ts";
45
+ import { loadSkills } from "../skills/loader.ts";
46
+ import { installSkills, removeSkill } from "../skills/installer.ts";
40
47
  import { saveGlobalConfig } from "../config/load.ts";
41
- import { logout as privateerLogout, hasCredentials } from "../auth/privateer.ts";
48
+ import { logout as privateerLogout, hasCredentials, onSessionExpired, warmSession } from "../auth/privateer.ts";
42
49
  import { RelayClient } from "../remote/relayClient.ts";
43
50
  import { ModeGate, type AskOutcome } from "../permissions/uiGate.ts";
44
51
  import type { PermissionRequest } from "../permissions/gate.ts";
52
+ import type { UserQuestion, UserAnswer, UserAsker } from "../tools/askUser.ts";
45
53
  import {
46
54
  saveSession,
47
55
  loadSession,
@@ -51,7 +59,7 @@ import {
51
59
  type SessionData,
52
60
  type SessionMeta,
53
61
  } from "../memory/store.ts";
54
- import { theme } from "./theme.ts";
62
+ import { theme, toolDisplayName } from "./theme.ts";
55
63
  import { DOWN } from "./figures.ts";
56
64
  import { randomVerb } from "./spinnerVerbs.ts";
57
65
 
@@ -60,12 +68,33 @@ interface PendingApproval {
60
68
  resolve: (outcome: AskOutcome) => void;
61
69
  }
62
70
 
71
+ // An `ask_user` question awaiting the user's choice in the TUI; mirrors how a
72
+ // PendingApproval parks a tool blocked on the human.
73
+ interface PendingQuestion {
74
+ q: UserQuestion;
75
+ resolve: (answer: UserAnswer) => void;
76
+ }
77
+
63
78
  const BANNER = "__banner__";
64
79
 
65
80
  function asText(output: unknown): string {
66
81
  return typeof output === "string" ? output : JSON.stringify(output);
67
82
  }
68
83
 
84
+ // Render the daemon's routine list for /routine.
85
+ function formatRoutines(routines: Routine[]): string {
86
+ if (routines.length === 0) {
87
+ return "No routines. Ask the agent to create one (e.g. \"summarize world news every morning\").";
88
+ }
89
+ const lines = routines.map((r) => {
90
+ const state = r.enabled ? "▶" : "⏸";
91
+ const next = r.enabled && r.nextRun ? new Date(r.nextRun).toLocaleString() : "paused";
92
+ const last = r.lastRun ? ` · last ${r.lastStatus ?? "?"} ${new Date(r.lastRun).toLocaleString()}` : "";
93
+ return ` ${state} ${r.name} — ${describeTrigger(r)} → ${next} [${r.delivery.join(",")}]${last}`;
94
+ });
95
+ return `Routines:\n${lines.join("\n")}\n\n/routine pause|resume|rm|run <name>`;
96
+ }
97
+
69
98
  // Rows of fixed chrome below the live transcript (spinner, todo, status bar, the
70
99
  // bordered input + mode hint) that the streaming text must leave room for, so the
71
100
  // dynamic region never outgrows the viewport and tips Ink into full-screen repaint.
@@ -95,13 +124,16 @@ function mergeAgentMetrics(
95
124
 
96
125
  // Project the committed transcript into structured feed items for a remote
97
126
  // controller's catch-up snapshot, mirroring the live event kinds the app renders.
127
+ // Whitespace-only assistant/thinking entries (possible in transcripts persisted
128
+ // before the empty-block guard in the turn loop) are dropped — the app would
129
+ // render each one as a blank gap in its feed.
98
130
  function snapshotEntries(entries: Entry[]): { kind: string; text: string }[] {
99
131
  const out: { kind: string; text: string }[] = [];
100
132
  for (const e of entries) {
101
133
  if (e.kind === "user") out.push({ kind: "you", text: e.text });
102
- else if (e.kind === "assistant") out.push({ kind: "assistant", text: e.text });
103
- else if (e.kind === "thinking") out.push({ kind: "reasoning", text: e.text });
104
- else if (e.kind === "tool") out.push({ kind: "tool", text: `▸ ${e.name} — ${e.status}` });
134
+ else if (e.kind === "assistant" && e.text.trim()) out.push({ kind: "assistant", text: e.text });
135
+ else if (e.kind === "thinking" && e.text.trim()) out.push({ kind: "reasoning", text: e.text });
136
+ else if (e.kind === "tool") out.push({ kind: "tool", text: `▸ ${toolDisplayName(e.name)} — ${e.status}` });
105
137
  else if (e.kind === "notice") out.push({ kind: "notice", text: e.text });
106
138
  }
107
139
  return out;
@@ -162,6 +194,7 @@ export function App({
162
194
  const [lastTurnUsage, setLastTurnUsage] = useState<UsageTotals>(emptyUsage());
163
195
  const [sessionError, setSessionError] = useState<string | null>(null);
164
196
  const [pending, setPending] = useState<PendingApproval | null>(null);
197
+ const [pendingQuestion, setPendingQuestion] = useState<PendingQuestion | null>(null);
165
198
  const [picking, setPicking] = useState(false);
166
199
  const [todos, setTodos] = useState<TodoItem[]>([]);
167
200
  const [verb, setVerb] = useState(randomVerb());
@@ -203,6 +236,9 @@ export function App({
203
236
  // rewrote to chips in the buffer text. Each turn claims the ones whose chip
204
237
  // survives into its submitted text, so the base64 still rides along.
205
238
  const pendingImagesRef = useRef<Attachment[]>([]);
239
+ // Files received from the app over the relay, awaiting the next remote prompt to
240
+ // ride along with (mirrors how drag/paste stages into pendingImagesRef).
241
+ const pendingRemoteAttachmentsRef = useRef<{ name: string; mediaType: string; base64: string }[]>([]);
206
242
  // Session-lifetime checkpoint store (survives model/style switches) for /rewind,
207
243
  // plus a live mirror of the committed transcript length for checkpointing. Bound to
208
244
  // the session's on-disk checkpoint dir so /rewind survives a restart-and-resume; a
@@ -250,7 +286,12 @@ export function App({
250
286
 
251
287
  // Custom slash commands from .privateer/commands, plus the merged autocomplete list.
252
288
  const customCommands = useMemo(() => loadCustomCommands(cwd), [cwd]);
253
- const commands = useMemo(() => commandList(customCommands), [customCommands]);
289
+ // Agent skills from .privateer/skills. The epoch bumps after /skills install|remove
290
+ // so this list — and the session, whose skill-tool catalog is baked in at build
291
+ // time — pick up the change.
292
+ const [skillsEpoch, setSkillsEpoch] = useState(0);
293
+ const skills = useMemo(() => loadSkills(cwd).skills, [cwd, skillsEpoch]);
294
+ const commands = useMemo(() => commandList(customCommands, skills), [customCommands, skills]);
254
295
  // Lifecycle hooks (UserPromptSubmit / Stop) configured in settings.
255
296
  const hooks = useMemo(() => new HookRunner(loadHooks((config as any).hooks), cwd), [cwd]);
256
297
 
@@ -298,6 +339,18 @@ export function App({
298
339
  [],
299
340
  );
300
341
 
342
+ // Bridge the `ask_user` tool to the TUI: park the question's resolver in state so
343
+ // the OptionPicker can render and resolve it, exactly like the approval prompt. A
344
+ // remote-driven turn has no local human to ask, so it resolves as dismissed and the
345
+ // model falls back to its own judgment.
346
+ const askUser = useMemo<UserAsker>(
347
+ () => (q) =>
348
+ currentTurnRemoteRef.current
349
+ ? Promise.resolve({ kind: "dismissed" as const })
350
+ : new Promise<UserAnswer>((resolve) => setPendingQuestion({ q, resolve })),
351
+ [],
352
+ );
353
+
301
354
  // Build (and rebuild on model / output-style change) the agent session, carrying
302
355
  // history forward.
303
356
  useEffect(() => {
@@ -309,6 +362,7 @@ export function App({
309
362
  modelSpec,
310
363
  cwd,
311
364
  gate,
365
+ askUser,
312
366
  confineToCwd: config.confineToCwd,
313
367
  allowedOutsideRoots: allowedOutsideRootsRef.current,
314
368
  outputStyle: outputStyle ?? undefined,
@@ -318,6 +372,16 @@ export function App({
318
372
  processes: processesRef.current,
319
373
  attachments: attachmentsRef.current,
320
374
  onSubAgentMetrics: (id, m) => subAgentMetricsRef.current.set(id, m),
375
+ // Read the ref at call time: the relay lives in its own effect (the
376
+ // /remote-access toggle), so this closure stays valid across session
377
+ // rebuilds and remote on/off flips.
378
+ sendFileToController: (file) => {
379
+ const client = relayRef.current;
380
+ if (!client) {
381
+ return Promise.resolve({ ok: false, reason: "remote access is off (/remote-access to enable)" });
382
+ }
383
+ return client.sendFile(file);
384
+ },
321
385
  });
322
386
  if (prev) {
323
387
  session.engine.messages.push(...prev.messages);
@@ -342,7 +406,7 @@ export function App({
342
406
  // Rebuild on model/style change, and when entering/leaving plan mode (so the
343
407
  // system prompt gains or loses the plan-mode mandate) — not on every mode change.
344
408
  // eslint-disable-next-line react-hooks/exhaustive-deps
345
- }, [modelSpec, outputStyle, mode === "plan", mcpTools, zdrEnforced]);
409
+ }, [modelSpec, outputStyle, mode === "plan", mcpTools, zdrEnforced, skillsEpoch]);
346
410
 
347
411
  // One-time notice when resuming a prior conversation.
348
412
  useEffect(() => {
@@ -354,6 +418,22 @@ export function App({
354
418
  }
355
419
  }, []);
356
420
 
421
+ // Surface any scheduled-routine results that finished while no terminal was
422
+ // attached ("notice" delivery). Drained once on startup.
423
+ useEffect(() => {
424
+ const pending = drainNotices();
425
+ if (pending.length === 0) return;
426
+ const lines = pending.map((n) => {
427
+ const mark = n.status === "ok" ? "⏺" : "✗";
428
+ const where = n.path ? ` (${n.path})` : "";
429
+ return ` ${mark} ${n.routine}: ${n.preview}${where}`;
430
+ });
431
+ setCommitted((c) => [
432
+ { kind: "notice", text: `Scheduled routine results:\n${lines.join("\n")}` },
433
+ ...c,
434
+ ]);
435
+ }, []);
436
+
357
437
  // Keep a live mirror of the committed transcript so checkpoints can record its
358
438
  // length synchronously (the useInput/runTurn closures can lag a render).
359
439
  useEffect(() => {
@@ -457,14 +537,61 @@ export function App({
457
537
  // remote prompt queues/dispatches against the current `busy`, never a stale one.
458
538
  handleInputRef.current = (value, opts) => handleInput(value, opts);
459
539
 
540
+ // Fold any files the app sent over the relay into a remote prompt. Binary kinds
541
+ // (image/pdf/…) become "[Kind #n]" chips backed by staged bytes that runTurn's
542
+ // liveAttachments filter then claims; text-like kinds are decoded and inlined as a
543
+ // fenced block. Returns the augmented prompt, or null when there's nothing to run
544
+ // (no text and no files). Mirrors the drag/paste staging into pendingImagesRef.
545
+ function consumeRemoteAttachments(text: string): string | null {
546
+ const files = pendingRemoteAttachmentsRef.current;
547
+ pendingRemoteAttachmentsRef.current = [];
548
+ if (files.length === 0) return text.trim() ? text : null;
549
+
550
+ const chips: string[] = [];
551
+ const inlined: string[] = [];
552
+ const maxInline = config.router?.inlineTextMaxBytes ?? 65_536;
553
+ for (const f of files) {
554
+ const modality = mediaModality(f.mediaType);
555
+ if (modality) {
556
+ const n = (imageSeqRef.current += 1);
557
+ const att: Attachment = { data: f.base64, mediaType: f.mediaType, modality, path: f.name, n };
558
+ pendingImagesRef.current.push(att);
559
+ chips.push(chipFor(att));
560
+ } else {
561
+ let body = "";
562
+ try { body = Buffer.from(f.base64, "base64").toString("utf8"); } catch { body = ""; }
563
+ if (body.length > maxInline) body = body.slice(0, maxInline) + `\n… (truncated, ${body.length - maxInline} more chars)`;
564
+ inlined.push(`\n\n${f.name}:\n\`\`\`\n${body}\n\`\`\``);
565
+ }
566
+ }
567
+ const head = text.trim();
568
+ const chipLine = chips.length ? (head ? " " : "") + chips.join(" ") : "";
569
+ const composed = `${head}${chipLine}${inlined.join("")}`.trim();
570
+ return composed.length ? composed : null;
571
+ }
572
+
460
573
  // Open/close the relay when /remote-access is toggled. The client is owned here
461
574
  // (mirrors mcpRef) and torn down on disable/unmount. On teardown we resolve any
462
575
  // parked approvals to "deny" so a dropped controller can't wedge a turn.
463
576
  useEffect(() => {
464
577
  if (!remoteEnabled) return;
465
578
  const client = new RelayClient({
466
- onPrompt: (text) => handleInputRef.current(text, { remote: true }),
579
+ onPrompt: (text) => {
580
+ const merged = consumeRemoteAttachments(text);
581
+ if (merged) handleInputRef.current(merged, { remote: true });
582
+ },
583
+ onAttachment: (file) => {
584
+ pendingRemoteAttachmentsRef.current.push(file);
585
+ append({ kind: "notice", text: `📎 received ${file.name} from app` });
586
+ },
467
587
  onInterrupt: () => abortRef.current?.abort(),
588
+ // The app's "End remote access" — same as typing /remote-access off. Flipping
589
+ // remoteEnabled runs this effect's cleanup: the client stops (no reconnect)
590
+ // and any parked approvals resolve to deny.
591
+ onTerminate: () => {
592
+ append({ kind: "notice", text: "Remote access turned off from the Privateer app. Use /remote-access on to re-enable." });
593
+ setRemoteEnabled(false);
594
+ },
468
595
  onApprovalResponse: (id, decision) => {
469
596
  const entry = pendingApprovalsRef.current.get(id);
470
597
  if (entry) {
@@ -520,8 +647,9 @@ export function App({
520
647
 
521
648
  useInput((input, key) => {
522
649
  if (key.ctrl && input === "c") exit();
523
- // Esc interrupts an in-flight turn (the run loop persists partial output).
524
- if (key.escape && busy && abortRef.current) abortRef.current.abort();
650
+ // Esc interrupts an in-flight turn (the run loop persists partial output) — but
651
+ // not while a question picker owns input, where Esc means "dismiss the question".
652
+ if (key.escape && busy && !pendingQuestion && abortRef.current) abortRef.current.abort();
525
653
  // Ctrl+O toggles detail level for the whole transcript: it expands/collapses
526
654
  // both the model's reasoning blocks and full tool output together. (Reasoning
527
655
  // only exists when extended thinking is enabled, so without also flipping tool
@@ -536,7 +664,16 @@ export function App({
536
664
  }
537
665
  // Shift+Tab rotates the permission mode — but not while a modal overlay owns
538
666
  // input (it has its own keybindings).
539
- if (key.tab && key.shift && !pending && !picking && !rewinding && !planReady && !sessionsPicking)
667
+ if (
668
+ key.tab &&
669
+ key.shift &&
670
+ !pending &&
671
+ !pendingQuestion &&
672
+ !picking &&
673
+ !rewinding &&
674
+ !planReady &&
675
+ !sessionsPicking
676
+ )
540
677
  cycleMode();
541
678
  });
542
679
 
@@ -553,8 +690,34 @@ export function App({
553
690
 
554
691
  const append = (...entries: Entry[]) => setCommitted((c) => [...c, ...entries]);
555
692
 
693
+ // Announce a Privateer sign-out the moment it happens. The machine login
694
+ // dies server-side when its refresh-token TTL lapses (only after weeks of
695
+ // no use — spawns slide it forward) or it's revoked, and — because the child
696
+ // session only spawns on demand — the CLI used to discover that on the first
697
+ // request after a boot, where the credentials were wiped silently. The
698
+ // listener covers every spawn path (startup, first prompt, relay tickets);
699
+ // warming the session up front when the active model bills to the account
700
+ // moves the announcement to launch instead of mid-turn.
701
+ useEffect(() => {
702
+ const unsub = onSessionExpired(() =>
703
+ append({
704
+ kind: "notice",
705
+ tone: "error",
706
+ text: "Signed out of your Privateer account — this machine's login expired.",
707
+ hint: "Run /login to sign back in. Account models stay listed under /model.",
708
+ }),
709
+ );
710
+ try {
711
+ if (parseModelSpec(modelSpec).provider === "privateer") void warmSession();
712
+ } catch {
713
+ /* malformed model spec — nothing to warm */
714
+ }
715
+ return unsub;
716
+ // eslint-disable-next-line react-hooks/exhaustive-deps
717
+ }, []);
718
+
556
719
  function handleCommand(raw: string): boolean {
557
- const res = runCommand(raw, { config, modelSpec, mode, usage, context, cwd, todos, customCommands });
720
+ const res = runCommand(raw, { config, modelSpec, mode, usage, context, cwd, todos, customCommands, skills });
558
721
  if (!res) return false;
559
722
  append({ kind: "user", text: raw });
560
723
  switch (res.type) {
@@ -586,6 +749,32 @@ export function App({
586
749
  case "runPrompt":
587
750
  void runTurn(res.text, { hideInput: true });
588
751
  break;
752
+ case "skillOp": {
753
+ const scope = res.project ? ("project" as const) : ("user" as const);
754
+ if (res.op === "install") {
755
+ append({ kind: "notice", text: `Installing skill(s) from ${res.arg}…` });
756
+ void installSkills(res.arg, { scope, all: res.all, force: res.force, cwd })
757
+ .then((installed) => {
758
+ append({
759
+ kind: "notice",
760
+ text: `Installed (${scope}): ${installed.map((s) => s.name).join(", ")}`,
761
+ });
762
+ setSkillsEpoch((e) => e + 1);
763
+ })
764
+ .catch((err) => {
765
+ append({ kind: "notice", tone: "error", text: err instanceof Error ? err.message : String(err) });
766
+ });
767
+ } else {
768
+ try {
769
+ const { dir } = removeSkill(res.arg, { scope: res.project ? "project" : undefined, cwd });
770
+ append({ kind: "notice", text: `Removed skill "${res.arg}" (${dir}).` });
771
+ setSkillsEpoch((e) => e + 1);
772
+ } catch (err) {
773
+ append({ kind: "notice", tone: "error", text: err instanceof Error ? err.message : String(err) });
774
+ }
775
+ }
776
+ break;
777
+ }
589
778
  case "compact":
590
779
  void doCompact();
591
780
  break;
@@ -710,6 +899,47 @@ export function App({
710
899
  });
711
900
  break;
712
901
  }
902
+ case "routine": {
903
+ const targeted = res.action !== "list";
904
+ if (targeted && !res.arg) {
905
+ append({ kind: "notice", tone: "error", text: `Usage: /routine ${res.action} <name>` });
906
+ break;
907
+ }
908
+ const req =
909
+ res.action === "list"
910
+ ? ({ cmd: "list" } as const)
911
+ : res.action === "pause"
912
+ ? ({ cmd: "pause", idOrName: res.arg! } as const)
913
+ : res.action === "resume"
914
+ ? ({ cmd: "resume", idOrName: res.arg! } as const)
915
+ : res.action === "remove"
916
+ ? ({ cmd: "remove", idOrName: res.arg! } as const)
917
+ : ({ cmd: "run-now", idOrName: res.arg! } as const);
918
+ void sendToDaemon(req)
919
+ .then((r) => {
920
+ if (!r.ok) {
921
+ append({ kind: "notice", tone: "error", text: r.message ?? "Command failed." });
922
+ return;
923
+ }
924
+ if (res.action === "list") {
925
+ append({ kind: "notice", text: formatRoutines(r.routines ?? []) });
926
+ } else {
927
+ append({ kind: "notice", text: r.message ?? "Done." });
928
+ }
929
+ })
930
+ .catch((err) => {
931
+ if (err instanceof DaemonNotRunningError) {
932
+ append({
933
+ kind: "notice",
934
+ tone: "error",
935
+ text: "Routine daemon isn't running. Start it with `privateer daemon` (or `privateer daemon --detach`).",
936
+ });
937
+ } else {
938
+ append({ kind: "notice", tone: "error", text: `Routine error: ${String(err)}` });
939
+ }
940
+ });
941
+ break;
942
+ }
713
943
  case "rewind":
714
944
  if (checkpointsRef.current.list().length === 0) {
715
945
  append({ kind: "notice", text: "No checkpoints yet — they're taken before each turn." });
@@ -969,10 +1199,16 @@ export function App({
969
1199
  }
970
1200
  for await (const ev of engine.send(sendText, controller.signal, attachments)) {
971
1201
  switch (ev.type) {
972
- case "text":
1202
+ case "text": {
973
1203
  thinkingIdx = -1;
974
1204
  if (assistantIdx === -1) {
975
- pushLive({ kind: "assistant", text: ev.text });
1205
+ // Models often emit a whitespace-only text block between tool
1206
+ // calls; opening an entry for it paints an empty ⏺ bullet. Hold
1207
+ // off until real text arrives, and drop the leading whitespace
1208
+ // when it does (it was only ever a separator).
1209
+ const opening = ev.text.replace(/^\s+/, "");
1210
+ if (!opening) break;
1211
+ pushLive({ kind: "assistant", text: opening });
976
1212
  assistantIdx = liveEntries.length - 1;
977
1213
  } else {
978
1214
  const idx = assistantIdx;
@@ -982,9 +1218,13 @@ export function App({
982
1218
  sync();
983
1219
  }
984
1220
  break;
985
- case "reasoning":
1221
+ }
1222
+ case "reasoning": {
986
1223
  if (thinkingIdx === -1) {
987
- pushLive({ kind: "thinking", text: ev.text });
1224
+ // Same whitespace-only guard as assistant text above.
1225
+ const opening = ev.text.replace(/^\s+/, "");
1226
+ if (!opening) break;
1227
+ pushLive({ kind: "thinking", text: opening });
988
1228
  thinkingIdx = liveEntries.length - 1;
989
1229
  } else {
990
1230
  const idx = thinkingIdx;
@@ -994,6 +1234,7 @@ export function App({
994
1234
  sync();
995
1235
  }
996
1236
  break;
1237
+ }
997
1238
  case "tool-call": {
998
1239
  // `task` calls carry the sub-agent's description/type so the grouped
999
1240
  // agents view can label each row before its metrics land.
@@ -1288,7 +1529,7 @@ export function App({
1288
1529
  no work to animate. Crucially, ink-spinner re-renders the whole dynamic
1289
1530
  region every frame; left running it would erase+redraw the bordered
1290
1531
  ApprovalPrompt below it ~10×/s, which reads as the box flickering. */}
1291
- {busy && !pending && (
1532
+ {busy && !pending && !pendingQuestion && (
1292
1533
  <Box marginTop={1} gap={1}>
1293
1534
  <Text color={theme.accent}>
1294
1535
  <Spinner type="dots" />
@@ -1313,6 +1554,7 @@ export function App({
1313
1554
  custom={statusText || undefined}
1314
1555
  zdr={zdr}
1315
1556
  tee={tee}
1557
+ remote={remoteEnabled}
1316
1558
  />
1317
1559
 
1318
1560
  {picking ? (
@@ -1332,6 +1574,14 @@ export function App({
1332
1574
  setPending(null);
1333
1575
  }}
1334
1576
  />
1577
+ ) : pendingQuestion ? (
1578
+ <OptionPicker
1579
+ question={pendingQuestion.q}
1580
+ onRespond={(answer) => {
1581
+ pendingQuestion.resolve(answer);
1582
+ setPendingQuestion(null);
1583
+ }}
1584
+ />
1335
1585
  ) : rewinding ? (
1336
1586
  <RewindPicker
1337
1587
  checkpoints={checkpointsRef.current.list()}
@@ -20,18 +20,29 @@ export function ApprovalPrompt({
20
20
  else if (c === "n" || key.escape) onRespond("deny");
21
21
  });
22
22
 
23
+ // Quiet cue for elevated-stakes requests — stays in the blue theme, just flags
24
+ // that this one is weightier than a routine approval. Order = most severe first.
25
+ const badge = req.alwaysAsk
26
+ ? "destructive"
27
+ : req.protected
28
+ ? "guarded file"
29
+ : req.outside
30
+ ? "outside cwd"
31
+ : undefined;
32
+
23
33
  return (
24
- <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.warning} paddingX={1}>
34
+ <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
25
35
  <Text>
26
- <Text bold color={theme.warning}>
36
+ <Text bold color={theme.accent}>
27
37
  {req.title}
28
38
  </Text>
29
39
  <Text dimColor> ({req.tool})</Text>
40
+ {badge && <Text dimColor> ⚠ {badge}</Text>}
30
41
  </Text>
31
42
  <Text>{req.detail}</Text>
32
43
  <Text dimColor>
33
- <Text color={theme.success}>y</Text> allow · <Text color={theme.success}>a</Text> always ·{" "}
34
- <Text color={theme.error}>n</Text> deny
44
+ <Text color={theme.accent}>y</Text> allow · <Text color={theme.accent}>a</Text> always ·{" "}
45
+ <Text color={theme.accentDim}>n</Text> deny
35
46
  </Text>
36
47
  </Box>
37
48
  );
@@ -4,6 +4,7 @@ import { Box, Text } from "ink";
4
4
  import { VERSION } from "../version.ts";
5
5
  import { theme } from "./theme.ts";
6
6
  import { WELCOME } from "./figures.ts";
7
+ import { currentUser } from "../auth/privateer.ts";
7
8
 
8
9
  // Collapse the user's home directory to ~ for a compact path display.
9
10
  function shortenPath(cwd: string): string {
@@ -13,6 +14,17 @@ function shortenPath(cwd: string): string {
13
14
  : cwd;
14
15
  }
15
16
 
17
+ // The Privateer account this terminal is signed into: email accounts show the
18
+ // email; wallet accounts (no email) show the first few characters of the Solana
19
+ // public key. Returns null when running unauthenticated (BYO key, no account).
20
+ function accountLabel(): string | null {
21
+ const user = currentUser();
22
+ if (!user) return null;
23
+ if (user.email) return user.email;
24
+ if (user.solanaPublicKey) return user.solanaPublicKey.slice(0, 6) + "…";
25
+ return null;
26
+ }
27
+
16
28
  // Anchor motif rendered in ASCII — the Privateer mark (ring, stock, shank, flukes).
17
29
  const ANCHOR = [
18
30
  " .-. ",
@@ -24,6 +36,7 @@ const ANCHOR = [
24
36
  ];
25
37
 
26
38
  export function Banner({ model }: { model: string }) {
39
+ const account = accountLabel();
27
40
  return (
28
41
  <Box flexDirection="column">
29
42
  <Box
@@ -44,7 +57,14 @@ export function Banner({ model }: { model: string }) {
44
57
  <Text bold color={theme.accent}>
45
58
  {WELCOME} PRIVATEER
46
59
  </Text>
47
- <Text color={theme.dim}>bring your own model · v{VERSION}</Text>
60
+ <Text color={theme.dim}>
61
+ bring your own model or connect to Privateer · v{VERSION}
62
+ </Text>
63
+ {account && (
64
+ <Text color={theme.dim}>
65
+ connected as <Text color={theme.accent}>{account}</Text>
66
+ </Text>
67
+ )}
48
68
  <Text> </Text>
49
69
  <Text>
50
70
  model <Text color={theme.accent}>{model}</Text>