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.
- 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 +268 -18
- package/src/components/ApprovalPrompt.tsx +15 -4
- package/src/components/Banner.tsx +21 -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/StatusBar.tsx +11 -1
- package/src/components/ToolCallView.tsx +4 -0
- package/src/components/Transcript.tsx +14 -7
- package/src/components/figures.ts +1 -0
- 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 +161 -6
- 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 +35 -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";
|
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1221
|
+
}
|
|
1222
|
+
case "reasoning": {
|
|
986
1223
|
if (thinkingIdx === -1) {
|
|
987
|
-
|
|
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.
|
|
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
|
);
|
|
@@ -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}>
|
|
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>
|