privateer-agent 0.1.1 → 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 (42) 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 +222 -16
  7. package/src/components/ApprovalPrompt.tsx +15 -4
  8. package/src/components/Banner.tsx +3 -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/ToolCallView.tsx +4 -0
  13. package/src/components/Transcript.tsx +14 -7
  14. package/src/components/theme.ts +2 -0
  15. package/src/config/paths.ts +2 -0
  16. package/src/context/systemPrompt.ts +9 -0
  17. package/src/daemon/index.ts +322 -0
  18. package/src/daemon/ipc.ts +127 -0
  19. package/src/engine/errors.ts +10 -0
  20. package/src/main.tsx +43 -1
  21. package/src/mcp/client.ts +16 -1
  22. package/src/permissions/gate.ts +5 -0
  23. package/src/permissions/mode.ts +4 -0
  24. package/src/permissions/uiGate.ts +4 -3
  25. package/src/remote/relayClient.ts +76 -4
  26. package/src/routines/cron.ts +109 -0
  27. package/src/routines/delivery.ts +75 -0
  28. package/src/routines/schema.ts +65 -0
  29. package/src/routines/store.ts +205 -0
  30. package/src/routines/toolSelect.ts +48 -0
  31. package/src/routines/trigger.ts +41 -0
  32. package/src/session.ts +37 -12
  33. package/src/skills/installer.ts +222 -0
  34. package/src/skills/loader.ts +88 -0
  35. package/src/tools/askUser.ts +92 -0
  36. package/src/tools/context.ts +14 -0
  37. package/src/tools/index.ts +14 -0
  38. package/src/tools/routine.ts +110 -0
  39. package/src/tools/sendFileToClient.ts +55 -0
  40. package/src/tools/skill.ts +44 -0
  41. package/src/tools/worktree.ts +145 -0
  42. package/src/util/images.ts +22 -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";
@@ -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());
@@ -253,7 +286,12 @@ export function App({
253
286
 
254
287
  // Custom slash commands from .privateer/commands, plus the merged autocomplete list.
255
288
  const customCommands = useMemo(() => loadCustomCommands(cwd), [cwd]);
256
- 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]);
257
295
  // Lifecycle hooks (UserPromptSubmit / Stop) configured in settings.
258
296
  const hooks = useMemo(() => new HookRunner(loadHooks((config as any).hooks), cwd), [cwd]);
259
297
 
@@ -301,6 +339,18 @@ export function App({
301
339
  [],
302
340
  );
303
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
+
304
354
  // Build (and rebuild on model / output-style change) the agent session, carrying
305
355
  // history forward.
306
356
  useEffect(() => {
@@ -312,6 +362,7 @@ export function App({
312
362
  modelSpec,
313
363
  cwd,
314
364
  gate,
365
+ askUser,
315
366
  confineToCwd: config.confineToCwd,
316
367
  allowedOutsideRoots: allowedOutsideRootsRef.current,
317
368
  outputStyle: outputStyle ?? undefined,
@@ -321,6 +372,16 @@ export function App({
321
372
  processes: processesRef.current,
322
373
  attachments: attachmentsRef.current,
323
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
+ },
324
385
  });
325
386
  if (prev) {
326
387
  session.engine.messages.push(...prev.messages);
@@ -345,7 +406,7 @@ export function App({
345
406
  // Rebuild on model/style change, and when entering/leaving plan mode (so the
346
407
  // system prompt gains or loses the plan-mode mandate) — not on every mode change.
347
408
  // eslint-disable-next-line react-hooks/exhaustive-deps
348
- }, [modelSpec, outputStyle, mode === "plan", mcpTools, zdrEnforced]);
409
+ }, [modelSpec, outputStyle, mode === "plan", mcpTools, zdrEnforced, skillsEpoch]);
349
410
 
350
411
  // One-time notice when resuming a prior conversation.
351
412
  useEffect(() => {
@@ -357,6 +418,22 @@ export function App({
357
418
  }
358
419
  }, []);
359
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
+
360
437
  // Keep a live mirror of the committed transcript so checkpoints can record its
361
438
  // length synchronously (the useInput/runTurn closures can lag a render).
362
439
  useEffect(() => {
@@ -508,6 +585,13 @@ export function App({
508
585
  append({ kind: "notice", text: `📎 received ${file.name} from app` });
509
586
  },
510
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
+ },
511
595
  onApprovalResponse: (id, decision) => {
512
596
  const entry = pendingApprovalsRef.current.get(id);
513
597
  if (entry) {
@@ -563,8 +647,9 @@ export function App({
563
647
 
564
648
  useInput((input, key) => {
565
649
  if (key.ctrl && input === "c") exit();
566
- // Esc interrupts an in-flight turn (the run loop persists partial output).
567
- 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();
568
653
  // Ctrl+O toggles detail level for the whole transcript: it expands/collapses
569
654
  // both the model's reasoning blocks and full tool output together. (Reasoning
570
655
  // only exists when extended thinking is enabled, so without also flipping tool
@@ -579,7 +664,16 @@ export function App({
579
664
  }
580
665
  // Shift+Tab rotates the permission mode — but not while a modal overlay owns
581
666
  // input (it has its own keybindings).
582
- 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
+ )
583
677
  cycleMode();
584
678
  });
585
679
 
@@ -596,8 +690,34 @@ export function App({
596
690
 
597
691
  const append = (...entries: Entry[]) => setCommitted((c) => [...c, ...entries]);
598
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
+
599
719
  function handleCommand(raw: string): boolean {
600
- 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 });
601
721
  if (!res) return false;
602
722
  append({ kind: "user", text: raw });
603
723
  switch (res.type) {
@@ -629,6 +749,32 @@ export function App({
629
749
  case "runPrompt":
630
750
  void runTurn(res.text, { hideInput: true });
631
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
+ }
632
778
  case "compact":
633
779
  void doCompact();
634
780
  break;
@@ -753,6 +899,47 @@ export function App({
753
899
  });
754
900
  break;
755
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
+ }
756
943
  case "rewind":
757
944
  if (checkpointsRef.current.list().length === 0) {
758
945
  append({ kind: "notice", text: "No checkpoints yet — they're taken before each turn." });
@@ -1012,10 +1199,16 @@ export function App({
1012
1199
  }
1013
1200
  for await (const ev of engine.send(sendText, controller.signal, attachments)) {
1014
1201
  switch (ev.type) {
1015
- case "text":
1202
+ case "text": {
1016
1203
  thinkingIdx = -1;
1017
1204
  if (assistantIdx === -1) {
1018
- 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 });
1019
1212
  assistantIdx = liveEntries.length - 1;
1020
1213
  } else {
1021
1214
  const idx = assistantIdx;
@@ -1025,9 +1218,13 @@ export function App({
1025
1218
  sync();
1026
1219
  }
1027
1220
  break;
1028
- case "reasoning":
1221
+ }
1222
+ case "reasoning": {
1029
1223
  if (thinkingIdx === -1) {
1030
- 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 });
1031
1228
  thinkingIdx = liveEntries.length - 1;
1032
1229
  } else {
1033
1230
  const idx = thinkingIdx;
@@ -1037,6 +1234,7 @@ export function App({
1037
1234
  sync();
1038
1235
  }
1039
1236
  break;
1237
+ }
1040
1238
  case "tool-call": {
1041
1239
  // `task` calls carry the sub-agent's description/type so the grouped
1042
1240
  // agents view can label each row before its metrics land.
@@ -1331,7 +1529,7 @@ export function App({
1331
1529
  no work to animate. Crucially, ink-spinner re-renders the whole dynamic
1332
1530
  region every frame; left running it would erase+redraw the bordered
1333
1531
  ApprovalPrompt below it ~10×/s, which reads as the box flickering. */}
1334
- {busy && !pending && (
1532
+ {busy && !pending && !pendingQuestion && (
1335
1533
  <Box marginTop={1} gap={1}>
1336
1534
  <Text color={theme.accent}>
1337
1535
  <Spinner type="dots" />
@@ -1376,6 +1574,14 @@ export function App({
1376
1574
  setPending(null);
1377
1575
  }}
1378
1576
  />
1577
+ ) : pendingQuestion ? (
1578
+ <OptionPicker
1579
+ question={pendingQuestion.q}
1580
+ onRespond={(answer) => {
1581
+ pendingQuestion.resolve(answer);
1582
+ setPendingQuestion(null);
1583
+ }}
1584
+ />
1379
1585
  ) : rewinding ? (
1380
1586
  <RewindPicker
1381
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
  );
@@ -57,7 +57,9 @@ export function Banner({ model }: { model: string }) {
57
57
  <Text bold color={theme.accent}>
58
58
  {WELCOME} PRIVATEER
59
59
  </Text>
60
- <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>
61
63
  {account && (
62
64
  <Text color={theme.dim}>
63
65
  connected as <Text color={theme.accent}>{account}</Text>
@@ -4,6 +4,7 @@ import Spinner from "ink-spinner";
4
4
  import TextInput from "ink-text-input";
5
5
  import type { Config, ProviderName } from "../config/schema.ts";
6
6
  import { configuredProviders, privateerChannel } from "../providers/resolve.ts";
7
+ import { hasCredentials } from "../auth/privateer.ts";
7
8
  import { PROVIDER_META } from "../providers/catalog.ts";
8
9
  import { listModels, zdrPosture, type ModelInfo } from "../providers/models.ts";
9
10
  import { theme, POSTURE_COLOR } from "./theme.ts";
@@ -27,14 +28,18 @@ export function ModelPicker({
27
28
  onSelect: (spec: string) => void;
28
29
  onCancel?: () => void;
29
30
  }) {
30
- const ready = useMemo(() => {
31
- const offered = providers ?? configuredProviders(config).filter((p) => p.ready).map((p) => p.name);
32
- return offered as ProviderName[];
31
+ const entries = useMemo<{ name: ProviderName; ready: boolean }[]>(() => {
32
+ if (providers) return providers.map((name) => ({ name, ready: true }));
33
+ // Ready providers, plus the Privateer account provider even when signed
34
+ // out: an expired machine login wipes the stored credentials, and the
35
+ // account silently vanishing from this list reads as a bug. It stays
36
+ // listed, annotated, and selecting it points at /login.
37
+ return configuredProviders(config).filter((p) => p.ready || p.name === "privateer");
33
38
  }, [config, providers]);
34
39
 
35
- const [provider, setProvider] = useState<ProviderName | null>(ready.length === 1 ? ready[0] : null);
40
+ const [provider, setProvider] = useState<ProviderName | null>(entries.length === 1 ? entries[0].name : null);
36
41
 
37
- if (ready.length === 0) {
42
+ if (entries.length === 0) {
38
43
  return (
39
44
  <Box flexDirection="column" paddingX={1}>
40
45
  <Text color={theme.error}>No providers configured. Run /login to add an API key.</Text>
@@ -43,7 +48,16 @@ export function ModelPicker({
43
48
  }
44
49
 
45
50
  if (provider === null) {
46
- return <ProviderStage providers={ready} onPick={setProvider} onCancel={onCancel} />;
51
+ return <ProviderStage providers={entries} onPick={setProvider} onCancel={onCancel} />;
52
+ }
53
+
54
+ if (provider === "privateer" && !hasCredentials()) {
55
+ return (
56
+ <SignedOutStage
57
+ onBack={entries.length > 1 ? () => setProvider(null) : undefined}
58
+ onCancel={onCancel}
59
+ />
60
+ );
47
61
  }
48
62
 
49
63
  return (
@@ -51,7 +65,7 @@ export function ModelPicker({
51
65
  provider={provider}
52
66
  config={config}
53
67
  onSelect={(id) => onSelect(`${provider}:${id}`)}
54
- onBack={ready.length > 1 ? () => setProvider(null) : undefined}
68
+ onBack={entries.length > 1 ? () => setProvider(null) : undefined}
55
69
  onCancel={onCancel}
56
70
  />
57
71
  );
@@ -62,7 +76,7 @@ function ProviderStage({
62
76
  onPick,
63
77
  onCancel,
64
78
  }: {
65
- providers: ProviderName[];
79
+ providers: { name: ProviderName; ready: boolean }[];
66
80
  onPick: (name: ProviderName) => void;
67
81
  onCancel?: () => void;
68
82
  }) {
@@ -70,7 +84,7 @@ function ProviderStage({
70
84
  useInput((input, key) => {
71
85
  if (key.upArrow || input === "k") setCursor((c) => (c - 1 + providers.length) % providers.length);
72
86
  else if (key.downArrow || input === "j") setCursor((c) => (c + 1) % providers.length);
73
- else if (key.return) onPick(providers[cursor]);
87
+ else if (key.return) onPick(providers[cursor].name);
74
88
  else if (key.escape) onCancel?.();
75
89
  });
76
90
 
@@ -81,10 +95,11 @@ function ProviderStage({
81
95
  <Text color={theme.accent}>enter</Text> select{onCancel ? ", esc cancel" : ""}.
82
96
  </Text>
83
97
  <Box flexDirection="column" marginTop={1}>
84
- {providers.map((name, i) => (
85
- <Text key={name} color={i === cursor ? theme.accent : undefined}>
98
+ {providers.map((p, i) => (
99
+ <Text key={p.name} color={i === cursor ? theme.accent : undefined}>
86
100
  {i === cursor ? "❯ " : " "}
87
- {PROVIDER_META[name].label}
101
+ {PROVIDER_META[p.name].label}
102
+ {!p.ready ? <Text color={theme.warning}> — signed out, run /login</Text> : null}
88
103
  </Text>
89
104
  ))}
90
105
  </Box>
@@ -92,6 +107,24 @@ function ProviderStage({
92
107
  );
93
108
  }
94
109
 
110
+ // The Privateer account provider is listed but signed out (fresh install, or
111
+ // the machine login's TTL lapsed and the credentials were wiped): explain how
112
+ // to get it back instead of failing the model fetch with a raw auth error.
113
+ function SignedOutStage({ onBack, onCancel }: { onBack?: () => void; onCancel?: () => void }) {
114
+ useInput((_input, key) => {
115
+ if (key.escape) (onBack ?? onCancel)?.();
116
+ });
117
+ return (
118
+ <Box flexDirection="column" paddingX={1}>
119
+ <Text color={theme.warning}>Not signed in to your Privateer account.</Text>
120
+ <Text color={theme.dim}>
121
+ Run /login to link this terminal, then pick an account model here.
122
+ {onBack ? " Esc to go back." : onCancel ? " Esc to cancel." : ""}
123
+ </Text>
124
+ </Box>
125
+ );
126
+ }
127
+
95
128
  function ModelStage({
96
129
  provider,
97
130
  config,