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.
- package/README.md +86 -33
- package/package.json +1 -1
- package/src/auth/privateer.ts +71 -1
- package/src/commands/custom.ts +52 -4
- package/src/commands/registry.ts +124 -5
- package/src/components/App.tsx +222 -16
- package/src/components/ApprovalPrompt.tsx +15 -4
- package/src/components/Banner.tsx +3 -1
- package/src/components/ModelPicker.tsx +45 -12
- package/src/components/OptionPicker.tsx +134 -0
- package/src/components/Root.tsx +30 -9
- package/src/components/ToolCallView.tsx +4 -0
- package/src/components/Transcript.tsx +14 -7
- package/src/components/theme.ts +2 -0
- package/src/config/paths.ts +2 -0
- package/src/context/systemPrompt.ts +9 -0
- package/src/daemon/index.ts +322 -0
- package/src/daemon/ipc.ts +127 -0
- package/src/engine/errors.ts +10 -0
- package/src/main.tsx +43 -1
- package/src/mcp/client.ts +16 -1
- package/src/permissions/gate.ts +5 -0
- package/src/permissions/mode.ts +4 -0
- package/src/permissions/uiGate.ts +4 -3
- package/src/remote/relayClient.ts +76 -4
- package/src/routines/cron.ts +109 -0
- package/src/routines/delivery.ts +75 -0
- package/src/routines/schema.ts +65 -0
- package/src/routines/store.ts +205 -0
- package/src/routines/toolSelect.ts +48 -0
- package/src/routines/trigger.ts +41 -0
- package/src/session.ts +37 -12
- package/src/skills/installer.ts +222 -0
- package/src/skills/loader.ts +88 -0
- package/src/tools/askUser.ts +92 -0
- package/src/tools/context.ts +14 -0
- package/src/tools/index.ts +14 -0
- package/src/tools/routine.ts +110 -0
- package/src/tools/sendFileToClient.ts +55 -0
- package/src/tools/skill.ts +44 -0
- package/src/tools/worktree.ts +145 -0
- package/src/util/images.ts +22 -0
package/src/components/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1221
|
+
}
|
|
1222
|
+
case "reasoning": {
|
|
1029
1223
|
if (thinkingIdx === -1) {
|
|
1030
|
-
|
|
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.
|
|
34
|
+
<Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
|
|
25
35
|
<Text>
|
|
26
|
-
<Text bold color={theme.
|
|
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.
|
|
34
|
-
<Text color={theme.
|
|
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}>
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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>(
|
|
40
|
+
const [provider, setProvider] = useState<ProviderName | null>(entries.length === 1 ? entries[0].name : null);
|
|
36
41
|
|
|
37
|
-
if (
|
|
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={
|
|
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={
|
|
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((
|
|
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,
|