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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { UserQuestion, UserAnswer } from "../tools/askUser.ts";
|
|
4
|
+
import { theme } from "./theme.ts";
|
|
5
|
+
|
|
6
|
+
// Interactive picker shown when the agent calls `ask_user` to choose between
|
|
7
|
+
// competing approaches. The turn is blocked on the human (like the approval prompt).
|
|
8
|
+
// Keys: ↑/↓ move, 1–N jump, Enter confirm, e (or the last row) write a custom answer,
|
|
9
|
+
// Esc dismiss. In multiSelect mode, Space/number toggles and Enter confirms the set.
|
|
10
|
+
export function OptionPicker({
|
|
11
|
+
question,
|
|
12
|
+
onRespond,
|
|
13
|
+
}: {
|
|
14
|
+
question: UserQuestion;
|
|
15
|
+
onRespond: (answer: UserAnswer) => void;
|
|
16
|
+
}) {
|
|
17
|
+
const { options, multiSelect } = question;
|
|
18
|
+
const otherIndex = options.length; // the trailing "write your own" row
|
|
19
|
+
const total = options.length + 1;
|
|
20
|
+
const [cursor, setCursor] = useState(0);
|
|
21
|
+
const [checked, setChecked] = useState<Set<number>>(new Set());
|
|
22
|
+
const [mode, setMode] = useState<"list" | "custom">("list");
|
|
23
|
+
const [draft, setDraft] = useState("");
|
|
24
|
+
|
|
25
|
+
function toggle(i: number) {
|
|
26
|
+
setChecked((prev) => {
|
|
27
|
+
const next = new Set(prev);
|
|
28
|
+
if (next.has(i)) next.delete(i);
|
|
29
|
+
else next.add(i);
|
|
30
|
+
return next;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function confirm() {
|
|
35
|
+
if (cursor === otherIndex) {
|
|
36
|
+
setMode("custom");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (multiSelect) {
|
|
40
|
+
const picks = checked.size > 0 ? [...checked] : [cursor];
|
|
41
|
+
onRespond({ kind: "selected", indices: picks.sort((a, b) => a - b) });
|
|
42
|
+
} else {
|
|
43
|
+
onRespond({ kind: "selected", indices: [cursor] });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
useInput((input, key) => {
|
|
48
|
+
if (mode === "custom") {
|
|
49
|
+
if (key.escape) {
|
|
50
|
+
setMode("list");
|
|
51
|
+
setDraft("");
|
|
52
|
+
} else if (key.return) {
|
|
53
|
+
const text = draft.trim();
|
|
54
|
+
if (text) onRespond({ kind: "custom", text });
|
|
55
|
+
} else if (key.backspace || key.delete) {
|
|
56
|
+
setDraft((d) => d.slice(0, -1));
|
|
57
|
+
} else if (input && !key.ctrl && !key.meta) {
|
|
58
|
+
setDraft((d) => d + input);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (key.upArrow) setCursor((c) => (c - 1 + total) % total);
|
|
64
|
+
else if (key.downArrow) setCursor((c) => (c + 1) % total);
|
|
65
|
+
else if (key.escape) onRespond({ kind: "dismissed" });
|
|
66
|
+
else if (input === "e") setMode("custom");
|
|
67
|
+
else if (input >= "1" && input <= "9") {
|
|
68
|
+
const i = Number(input) - 1;
|
|
69
|
+
if (i < options.length) {
|
|
70
|
+
if (multiSelect) {
|
|
71
|
+
setCursor(i);
|
|
72
|
+
toggle(i);
|
|
73
|
+
} else {
|
|
74
|
+
onRespond({ kind: "selected", indices: [i] });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} else if (input === " " && multiSelect && cursor < options.length) {
|
|
78
|
+
toggle(cursor);
|
|
79
|
+
} else if (key.return) {
|
|
80
|
+
confirm();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (mode === "custom") {
|
|
85
|
+
return (
|
|
86
|
+
<Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
|
|
87
|
+
<Text bold color={theme.accent}>
|
|
88
|
+
{question.question}
|
|
89
|
+
</Text>
|
|
90
|
+
<Text>
|
|
91
|
+
<Text color={theme.dim}>your answer: </Text>
|
|
92
|
+
{draft}
|
|
93
|
+
<Text color={theme.accent}>▏</Text>
|
|
94
|
+
</Text>
|
|
95
|
+
<Text dimColor>enter submit · esc back to options</Text>
|
|
96
|
+
</Box>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
|
|
102
|
+
<Text bold color={theme.accent}>
|
|
103
|
+
{question.question}
|
|
104
|
+
</Text>
|
|
105
|
+
{options.map((o, i) => {
|
|
106
|
+
const active = i === cursor;
|
|
107
|
+
const box = multiSelect ? (checked.has(i) ? "[x] " : "[ ] ") : "";
|
|
108
|
+
return (
|
|
109
|
+
<Box key={i} flexDirection="column" marginTop={1}>
|
|
110
|
+
<Text color={active ? theme.accent : undefined}>
|
|
111
|
+
<Text color={active ? theme.accent : theme.dim}>{active ? "❯ " : " "}</Text>
|
|
112
|
+
<Text color={theme.dim}>{i + 1}. </Text>
|
|
113
|
+
{box}
|
|
114
|
+
<Text bold={active}>{o.label}</Text>
|
|
115
|
+
</Text>
|
|
116
|
+
{o.description ? <Text dimColor>{" " + o.description}</Text> : null}
|
|
117
|
+
</Box>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
<Box marginTop={1}>
|
|
121
|
+
<Text color={cursor === otherIndex ? theme.accent : undefined}>
|
|
122
|
+
<Text color={cursor === otherIndex ? theme.accent : theme.dim}>{cursor === otherIndex ? "❯ " : " "}</Text>
|
|
123
|
+
<Text color={theme.dim}>e. </Text>
|
|
124
|
+
<Text bold={cursor === otherIndex}>Something else — write your own answer</Text>
|
|
125
|
+
</Text>
|
|
126
|
+
</Box>
|
|
127
|
+
<Text dimColor>
|
|
128
|
+
{multiSelect
|
|
129
|
+
? "↑/↓ move · space toggle · enter confirm · esc skip"
|
|
130
|
+
: "↑/↓ move · 1–" + options.length + " pick · enter confirm · esc skip"}
|
|
131
|
+
</Text>
|
|
132
|
+
</Box>
|
|
133
|
+
);
|
|
134
|
+
}
|
package/src/components/Root.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { useState } from "react";
|
|
2
|
+
import { useStdout } from "ink";
|
|
2
3
|
import { App } from "./App.tsx";
|
|
3
4
|
import { Onboarding, type OnboardingResult } from "./Onboarding.tsx";
|
|
4
5
|
import { PrivateerLogin } from "./PrivateerLogin.tsx";
|
|
@@ -30,6 +31,17 @@ export function Root({
|
|
|
30
31
|
const [modelSpec, setModelSpec] = useState(initialModel);
|
|
31
32
|
const [onboarding, setOnboarding] = useState(startInOnboarding);
|
|
32
33
|
const [loggingIn, setLoggingIn] = useState(false);
|
|
34
|
+
const { stdout } = useStdout();
|
|
35
|
+
|
|
36
|
+
// The App's banner/transcript lives in Ink's <Static> region, which stays in the
|
|
37
|
+
// terminal scrollback even after the component unmounts. Swapping screens (login,
|
|
38
|
+
// onboarding) and back therefore stacks a second banner under the stale one. Wipe
|
|
39
|
+
// screen + scrollback around every top-level swap so each view starts on a clean
|
|
40
|
+
// page — same trick App uses when the terminal is resized.
|
|
41
|
+
function swapScreen(update: () => void) {
|
|
42
|
+
stdout?.write("\x1b[2J\x1b[3J\x1b[H"); // clear screen + scrollback, home cursor
|
|
43
|
+
update();
|
|
44
|
+
}
|
|
33
45
|
|
|
34
46
|
// Providers that already have credentials — pre-checked when re-running onboarding.
|
|
35
47
|
const configured = configuredProviders(config)
|
|
@@ -47,9 +59,11 @@ export function Root({
|
|
|
47
59
|
} catch {
|
|
48
60
|
/* non-fatal: keys just won't persist to disk this run */
|
|
49
61
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
swapScreen(() => {
|
|
63
|
+
setConfig(next);
|
|
64
|
+
setModelSpec(result.defaultModel);
|
|
65
|
+
setOnboarding(false);
|
|
66
|
+
});
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
// After a successful account login, switch to the Privateer-billed model so the
|
|
@@ -68,13 +82,20 @@ export function Root({
|
|
|
68
82
|
} catch {
|
|
69
83
|
/* non-fatal: model choice just won't persist this run */
|
|
70
84
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
swapScreen(() => {
|
|
86
|
+
setConfig(next);
|
|
87
|
+
setModelSpec(spec);
|
|
88
|
+
setLoggingIn(false);
|
|
89
|
+
});
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
if (loggingIn) {
|
|
77
|
-
return
|
|
93
|
+
return (
|
|
94
|
+
<PrivateerLogin
|
|
95
|
+
onComplete={finishLogin}
|
|
96
|
+
onCancel={() => swapScreen(() => setLoggingIn(false))}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
if (onboarding) {
|
|
@@ -88,8 +109,8 @@ export function Root({
|
|
|
88
109
|
config={config}
|
|
89
110
|
cwd={cwd}
|
|
90
111
|
resume={resume}
|
|
91
|
-
onLogin={() => setOnboarding(true)}
|
|
92
|
-
onPrivateerLogin={() => setLoggingIn(true)}
|
|
112
|
+
onLogin={() => swapScreen(() => setOnboarding(true))}
|
|
113
|
+
onPrivateerLogin={() => swapScreen(() => setLoggingIn(true))}
|
|
93
114
|
/>
|
|
94
115
|
);
|
|
95
116
|
}
|
|
@@ -21,6 +21,10 @@ function summarizeInput(name: string, input: unknown): string {
|
|
|
21
21
|
return String(o.pattern ?? "") + (o.glob ? ` (${o.glob})` : "");
|
|
22
22
|
case "task":
|
|
23
23
|
return String(o.description ?? o.subagent_type ?? "");
|
|
24
|
+
case "ask_user":
|
|
25
|
+
return String(o.question ?? "");
|
|
26
|
+
case "worktree":
|
|
27
|
+
return String(o.action ?? "") + (o.name ? ` ${o.name}` : "");
|
|
24
28
|
default:
|
|
25
29
|
try {
|
|
26
30
|
return JSON.stringify(o);
|
|
@@ -102,20 +102,26 @@ export function EntryView({
|
|
|
102
102
|
// Split off a trailing `recap:` line so it can render dimmed below the
|
|
103
103
|
// response body. The model is asked to end each turn with one such line.
|
|
104
104
|
const { body, recap } = splitRecap(entry.text);
|
|
105
|
+
const hasBody = body.trim().length > 0;
|
|
106
|
+
// Nothing to paint: a whitespace-only entry (possible in transcripts
|
|
107
|
+
// persisted before the turn loop filtered them) would render a bare ⏺.
|
|
108
|
+
if (!hasBody && !recap) return null;
|
|
105
109
|
// ⏺ bullet in its own column so wrapped lines align under the text.
|
|
106
110
|
return (
|
|
107
111
|
<Box marginTop={1} flexDirection="column">
|
|
108
|
-
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
<
|
|
112
|
+
{hasBody && (
|
|
113
|
+
<Box>
|
|
114
|
+
<Text color={theme.accent}>{BULLET} </Text>
|
|
115
|
+
<Box width={bodyWidth}>
|
|
116
|
+
<Markdown text={body} />
|
|
117
|
+
</Box>
|
|
112
118
|
</Box>
|
|
113
|
-
|
|
119
|
+
)}
|
|
114
120
|
{recap && (
|
|
115
|
-
<Box marginTop={1}>
|
|
121
|
+
<Box marginTop={hasBody ? 1 : 0}>
|
|
116
122
|
<Text color={theme.dim}>{" "}</Text>
|
|
117
123
|
<Box width={bodyWidth}>
|
|
118
|
-
<Text color=
|
|
124
|
+
<Text color={theme.dim} dimColor italic>
|
|
119
125
|
{recap}
|
|
120
126
|
</Text>
|
|
121
127
|
</Box>
|
|
@@ -127,6 +133,7 @@ export function EntryView({
|
|
|
127
133
|
case "thinking": {
|
|
128
134
|
// The model's reasoning, rendered dimmed under a thinking mark. When
|
|
129
135
|
// collapsed (Ctrl+O), show just a one-line summary instead of the full text.
|
|
136
|
+
if (entry.text.trim() === "") return null;
|
|
130
137
|
if (collapsed) {
|
|
131
138
|
const lineCount = entry.text.trim() === "" ? 0 : entry.text.trim().split("\n").length;
|
|
132
139
|
return (
|
package/src/components/theme.ts
CHANGED
|
@@ -50,6 +50,8 @@ const TOOL_DISPLAY: Record<string, string> = {
|
|
|
50
50
|
task: "Task",
|
|
51
51
|
web_fetch: "WebFetch",
|
|
52
52
|
web_search: "WebSearch",
|
|
53
|
+
ask_user: "AskUser",
|
|
54
|
+
worktree: "Worktree",
|
|
53
55
|
};
|
|
54
56
|
|
|
55
57
|
export const toolDisplayName = (name: string): string => TOOL_DISPLAY[name] ?? name;
|
package/src/config/paths.ts
CHANGED
|
@@ -35,6 +35,7 @@ export interface ScopePaths {
|
|
|
35
35
|
settingsLocal: string; // settings.local.json — gitignored local overrides
|
|
36
36
|
commands: string; // commands/ — custom slash commands (M2)
|
|
37
37
|
agents: string; // agents/ — custom subagents (M4)
|
|
38
|
+
skills: string; // skills/ — agent skills, one SKILL.md directory each
|
|
38
39
|
outputStyles: string; // output-styles/ — persona prompts (M2)
|
|
39
40
|
mcp: string; // mcp.json — MCP server declarations (M4)
|
|
40
41
|
}
|
|
@@ -47,6 +48,7 @@ function scopePaths(dir: string): ScopePaths {
|
|
|
47
48
|
settingsLocal: join(dir, "settings.local.json"),
|
|
48
49
|
commands: join(dir, "commands"),
|
|
49
50
|
agents: join(dir, "agents"),
|
|
51
|
+
skills: join(dir, "skills"),
|
|
50
52
|
outputStyles: join(dir, "output-styles"),
|
|
51
53
|
mcp: join(dir, "mcp.json"),
|
|
52
54
|
};
|
|
@@ -51,6 +51,15 @@ before doing it.
|
|
|
51
51
|
work is attributed to Privateer as a co-author:
|
|
52
52
|
Co-Authored-By: Privateer <291203302+privateer-first-mate@users.noreply.github.com>
|
|
53
53
|
- Use 'web_fetch' to read a known URL when the user provides one or you need current docs.
|
|
54
|
+
- When the implementation direction is genuinely ambiguous and the choice materially shapes the \
|
|
55
|
+
work — a structural/architectural fork, which library or data model to adopt, the scope of a \
|
|
56
|
+
change — don't silently guess. Lay out the realistic approaches and call 'ask_user' to let the \
|
|
57
|
+
user pick (2–4 options, most-recommended first, each with its trade-offs). Reserve it for \
|
|
58
|
+
consequential forks: for small, reversible choices, just make the call and proceed. After the \
|
|
59
|
+
user chooses, build that direction without re-litigating it.
|
|
60
|
+
- To try a risky or exploratory approach without disturbing the main tree, use 'worktree' to spin \
|
|
61
|
+
up an isolated branch + working copy, do the work there, and let the user compare its diff before \
|
|
62
|
+
keeping or discarding it.
|
|
54
63
|
- Mutating actions (write/edit/bash) may require user approval; that's expected — proceed and let \
|
|
55
64
|
the gate handle it.`;
|
|
56
65
|
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { Server } from "node:net";
|
|
2
|
+
import type { ToolSet } from "ai";
|
|
3
|
+
import { loadConfig } from "../config/load.ts";
|
|
4
|
+
import { createSession } from "../session.ts";
|
|
5
|
+
import { autoApproveGate } from "../permissions/gate.ts";
|
|
6
|
+
import { loadMcpServers, connectMcpServers } from "../mcp/client.ts";
|
|
7
|
+
import { RelayClient } from "../remote/relayClient.ts";
|
|
8
|
+
import { hasCredentials, revokeChildSession } from "../auth/privateer.ts";
|
|
9
|
+
import {
|
|
10
|
+
loadRoutines,
|
|
11
|
+
upsertRoutine,
|
|
12
|
+
findRoutine,
|
|
13
|
+
removeRoutine,
|
|
14
|
+
addPendingRelay,
|
|
15
|
+
drainPendingRelay,
|
|
16
|
+
routineRelayId,
|
|
17
|
+
} from "../routines/store.ts";
|
|
18
|
+
import type { Routine } from "../routines/schema.ts";
|
|
19
|
+
import { triggerError, computeNextRun, advanceAfterRun } from "../routines/trigger.ts";
|
|
20
|
+
import { splitRoutineTools, filterMcpTools } from "../routines/toolSelect.ts";
|
|
21
|
+
import { deliver, type RelayPusher } from "../routines/delivery.ts";
|
|
22
|
+
import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
23
|
+
|
|
24
|
+
// The safe, read-only-plus-web toolset for unattended runs. No write/edit/bash, so
|
|
25
|
+
// a routine firing with nobody watching can't mutate the filesystem or shell out.
|
|
26
|
+
const SAFE_TOOLS = ["read", "glob", "grep", "web_fetch", "web_search"];
|
|
27
|
+
|
|
28
|
+
const TICK_MS = 60_000; // scan for due routines once a minute
|
|
29
|
+
|
|
30
|
+
function log(msg: string): void {
|
|
31
|
+
process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Render a run's result as a self-contained markdown document for file/notice output.
|
|
35
|
+
function formatResult(routine: Routine, body: string, status: "ok" | "error", error?: string): string {
|
|
36
|
+
const when = new Date().toISOString();
|
|
37
|
+
const head = `# ${routine.name}\n\n_${when} · ${status}${routine.model ? ` · ${routine.model}` : ""}_\n\n`;
|
|
38
|
+
if (status === "error") return `${head}**Run failed:** ${error ?? "unknown error"}\n\n${body}`.trimEnd() + "\n";
|
|
39
|
+
return `${head}${body.trim() || "(no output)"}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class Daemon {
|
|
43
|
+
private server?: Server;
|
|
44
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
45
|
+
private readonly startedAt = Date.now();
|
|
46
|
+
// Set of routine ids currently executing, so a slow run can't be re-entered by
|
|
47
|
+
// the next tick.
|
|
48
|
+
private readonly running = new Set<string>();
|
|
49
|
+
// Outbound relay connection to the Privateer server, opened lazily when a signed-in
|
|
50
|
+
// user has a routine that delivers over `relay`. Pushes results to an attached
|
|
51
|
+
// controller (e.g. the mobile app) in real time.
|
|
52
|
+
private relay?: RelayClient;
|
|
53
|
+
// Best-effort "is a controller attached right now?" — set on controller_attached,
|
|
54
|
+
// cleared when our socket drops. Used to decide push-live vs queue-for-later.
|
|
55
|
+
private controllerAttached = false;
|
|
56
|
+
// The app sent `terminate` (End remote access): keep the relay down until the
|
|
57
|
+
// daemon restarts, even if routine edits re-run syncRelay. Results still queue
|
|
58
|
+
// durably and deliver over the other channels meanwhile.
|
|
59
|
+
private relayTerminated = false;
|
|
60
|
+
// Push a result live if a controller is attached; otherwise persist it to the
|
|
61
|
+
// pending queue so it flushes the moment the app next attaches. Either path is
|
|
62
|
+
// durable, so delivery treats both as handled.
|
|
63
|
+
private readonly pushRelay: RelayPusher = (routine, content) => {
|
|
64
|
+
if (this.controllerAttached && this.relay?.sendRoutineResult(routine.name, content)) return "live";
|
|
65
|
+
addPendingRelay({ routine: routine.name, at: new Date().toISOString(), content });
|
|
66
|
+
return "queued";
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
start(): void {
|
|
70
|
+
// Prime nextRun for any routine missing one, then start the loop + IPC server.
|
|
71
|
+
this.primeSchedule();
|
|
72
|
+
this.timer = setInterval(() => void this.tick(), TICK_MS);
|
|
73
|
+
this.server = startIpcServer((req) => this.handleIpc(req));
|
|
74
|
+
this.syncRelay();
|
|
75
|
+
const count = loadRoutines().filter((r) => r.enabled).length;
|
|
76
|
+
log(`daemon started (pid ${process.pid}); ${count} enabled routine(s). Tick every ${TICK_MS / 1000}s.`);
|
|
77
|
+
// Fire an immediate scan so a just-due routine doesn't wait a full minute.
|
|
78
|
+
void this.tick();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
stop(): void {
|
|
82
|
+
if (this.timer) clearInterval(this.timer);
|
|
83
|
+
this.server?.close();
|
|
84
|
+
this.relay?.stop();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Open the relay connection when it's both wanted (a signed-in account + at least
|
|
88
|
+
// one enabled routine delivering over `relay`) and not already up. Started ahead
|
|
89
|
+
// of fire time so it's connected when a routine actually pushes. We never tear it
|
|
90
|
+
// down once up — an idle authenticated socket is cheap and reconnects itself.
|
|
91
|
+
private syncRelay(): void {
|
|
92
|
+
if (this.relay || this.relayTerminated) return;
|
|
93
|
+
if (!hasCredentials()) return;
|
|
94
|
+
const wantsRelay = loadRoutines().some((r) => r.enabled && r.delivery.includes("relay"));
|
|
95
|
+
if (!wantsRelay) return;
|
|
96
|
+
this.relay = new RelayClient({
|
|
97
|
+
// The daemon publishes results but is not a drivable terminal: ignore any
|
|
98
|
+
// prompts/approvals a controller might send.
|
|
99
|
+
onPrompt: () => {},
|
|
100
|
+
onInterrupt: () => {},
|
|
101
|
+
onApprovalResponse: () => {},
|
|
102
|
+
onControllerAttached: () => this.onControllerAttached(),
|
|
103
|
+
onAttachment: () => {},
|
|
104
|
+
onTerminate: () => {
|
|
105
|
+
this.relayTerminated = true;
|
|
106
|
+
this.controllerAttached = false;
|
|
107
|
+
this.relay?.stop();
|
|
108
|
+
this.relay = undefined;
|
|
109
|
+
log("relay terminated from the app; staying offline until the daemon restarts");
|
|
110
|
+
},
|
|
111
|
+
onStatus: (text) => log(`relay: ${text}`),
|
|
112
|
+
onDisconnected: () => {
|
|
113
|
+
this.controllerAttached = false;
|
|
114
|
+
},
|
|
115
|
+
}, {
|
|
116
|
+
// Stable identity so the daemon shows up as one recognizable terminal in the
|
|
117
|
+
// app across restarts, instead of a fresh random "terminal-xxxx" each boot.
|
|
118
|
+
termId: routineRelayId(),
|
|
119
|
+
label: "Privateer Routines",
|
|
120
|
+
});
|
|
121
|
+
void this.relay.start();
|
|
122
|
+
log("relay connection starting (routine has relay delivery + account signed in)");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The app attached: greet it, then flush any routine results that finished while it
|
|
126
|
+
// was closed so it catches up immediately (in fire order).
|
|
127
|
+
private onControllerAttached(): void {
|
|
128
|
+
this.controllerAttached = true;
|
|
129
|
+
this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
|
|
130
|
+
const pending = drainPendingRelay();
|
|
131
|
+
if (pending.length === 0) return;
|
|
132
|
+
log(`controller attached — flushing ${pending.length} pending routine result(s)`);
|
|
133
|
+
for (const p of pending) this.relay?.sendRoutineResult(p.routine, p.content);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private primeSchedule(): void {
|
|
137
|
+
for (const r of loadRoutines()) {
|
|
138
|
+
if (!r.enabled) continue;
|
|
139
|
+
if (r.nextRun && !Number.isNaN(Date.parse(r.nextRun))) continue;
|
|
140
|
+
const nr = computeNextRun(r);
|
|
141
|
+
if (nr) this.persistRun(r.id, { nextRun: nr.toISOString() });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async tick(): Promise<void> {
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
for (const r of loadRoutines()) {
|
|
148
|
+
if (!r.enabled || this.running.has(r.id)) continue;
|
|
149
|
+
if (triggerError(r)) continue; // skip malformed triggers
|
|
150
|
+
if (!r.nextRun) {
|
|
151
|
+
const nr = computeNextRun(r);
|
|
152
|
+
this.persistRun(r.id, { nextRun: nr?.toISOString() });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (Date.parse(r.nextRun) <= now) {
|
|
156
|
+
await this.runRoutine(r);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Execute a routine to completion and deliver the result. Advances nextRun past
|
|
162
|
+
// now afterwards (skipping any backlog) so at most one run fires per tick.
|
|
163
|
+
async runRoutine(routine: Routine): Promise<IpcResponse> {
|
|
164
|
+
if (this.running.has(routine.id)) return { ok: false, message: "already running" };
|
|
165
|
+
this.running.add(routine.id);
|
|
166
|
+
log(`running routine "${routine.name}"`);
|
|
167
|
+
|
|
168
|
+
const config = loadConfig();
|
|
169
|
+
const modelSpec = routine.model ?? config.defaultModel;
|
|
170
|
+
const split = splitRoutineTools(routine.tools);
|
|
171
|
+
// If the routine names no builtin tools, it still gets the safe read/web set —
|
|
172
|
+
// a routine that only lists MCP selectors shouldn't lose the ability to read.
|
|
173
|
+
const allowedTools = split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
174
|
+
const wantsEmail = routine.delivery.includes("email");
|
|
175
|
+
|
|
176
|
+
// MCP tools are fulfilled inside the agent turn. Two grants exist: explicit
|
|
177
|
+
// "<server>__<tool>" selectors in routine.tools (least privilege: only the named
|
|
178
|
+
// servers are launched and only the selected tools exposed), and the legacy email
|
|
179
|
+
// delivery, which exposes every configured server so the mail tool is reachable.
|
|
180
|
+
// Either way, egress stays an explicit tool action rather than a side channel.
|
|
181
|
+
let extraTools: ToolSet | undefined;
|
|
182
|
+
let closeMcp: (() => void) | undefined;
|
|
183
|
+
let prompt = routine.prompt;
|
|
184
|
+
if (split.mcp.length > 0 || wantsEmail) {
|
|
185
|
+
try {
|
|
186
|
+
const all = loadMcpServers(routine.cwd);
|
|
187
|
+
for (const s of split.servers) {
|
|
188
|
+
if (!all[s]) log(` mcp: server "${s}" not configured in mcp.json — skipping`);
|
|
189
|
+
}
|
|
190
|
+
const servers = wantsEmail
|
|
191
|
+
? all
|
|
192
|
+
: Object.fromEntries(Object.entries(all).filter(([name]) => split.servers.includes(name)));
|
|
193
|
+
const conn = await connectMcpServers(servers, routine.cwd, autoApproveGate);
|
|
194
|
+
const selected = filterMcpTools(conn.tools, split.mcp);
|
|
195
|
+
// Email needs every server's tools (the mail tool isn't in the selectors);
|
|
196
|
+
// otherwise expose only what the routine was granted.
|
|
197
|
+
extraTools = wantsEmail ? conn.tools : selected;
|
|
198
|
+
closeMcp = () => conn.clients.forEach((c) => c.close());
|
|
199
|
+
if (split.mcp.length > 0 && Object.keys(selected).length === 0) {
|
|
200
|
+
log(` mcp: selectors matched no tools (${split.mcp.join(", ")})`);
|
|
201
|
+
}
|
|
202
|
+
if (wantsEmail) {
|
|
203
|
+
prompt +=
|
|
204
|
+
"\n\nWhen finished, email the result to the account owner using the available mail tool " +
|
|
205
|
+
"(e.g. a Gmail create/send tool). Keep the subject short and put the summary in the body.";
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
log(` mcp setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let out = "";
|
|
213
|
+
let status: "ok" | "error" = "ok";
|
|
214
|
+
let error: string | undefined;
|
|
215
|
+
try {
|
|
216
|
+
const session = createSession({
|
|
217
|
+
config,
|
|
218
|
+
modelSpec,
|
|
219
|
+
cwd: routine.cwd,
|
|
220
|
+
gate: autoApproveGate,
|
|
221
|
+
confineToCwd: true,
|
|
222
|
+
allowedTools,
|
|
223
|
+
extraTools,
|
|
224
|
+
});
|
|
225
|
+
for await (const ev of session.engine.send(prompt)) {
|
|
226
|
+
if (ev.type === "text") out += ev.text;
|
|
227
|
+
else if (ev.type === "error") {
|
|
228
|
+
status = "error";
|
|
229
|
+
error = ev.error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
status = "error";
|
|
234
|
+
error = err instanceof Error ? err.message : String(err);
|
|
235
|
+
} finally {
|
|
236
|
+
closeMcp?.();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const content = formatResult(routine, out, status, error);
|
|
240
|
+
const report = deliver(routine, content, status, { pushRelay: this.pushRelay });
|
|
241
|
+
log(` "${routine.name}" ${status}; delivered via ${report.delivered.join(", ") || "(none)"}`);
|
|
242
|
+
|
|
243
|
+
// Recurring routines reschedule; one-offs disable themselves after firing.
|
|
244
|
+
this.persistRun(routine.id, {
|
|
245
|
+
lastRun: new Date().toISOString(),
|
|
246
|
+
lastStatus: status,
|
|
247
|
+
lastError: error,
|
|
248
|
+
...advanceAfterRun(routine),
|
|
249
|
+
});
|
|
250
|
+
this.running.delete(routine.id);
|
|
251
|
+
return { ok: status === "ok", message: report.delivered.join(", ") || undefined };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Merge run bookkeeping into the persisted routine, re-reading first so we don't
|
|
255
|
+
// clobber concurrent IPC edits (add/pause/remove).
|
|
256
|
+
private persistRun(id: string, patch: Partial<Routine>): void {
|
|
257
|
+
const current = findRoutine(loadRoutines(), id);
|
|
258
|
+
if (!current) return;
|
|
259
|
+
upsertRoutine({ ...current, ...patch });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async handleIpc(req: IpcRequest): Promise<IpcResponse> {
|
|
263
|
+
switch (req.cmd) {
|
|
264
|
+
case "status":
|
|
265
|
+
return { ok: true, pid: process.pid, uptimeSec: Math.round((Date.now() - this.startedAt) / 1000), routines: loadRoutines() };
|
|
266
|
+
case "list":
|
|
267
|
+
return { ok: true, routines: loadRoutines() };
|
|
268
|
+
case "add": {
|
|
269
|
+
const err = triggerError(req.routine);
|
|
270
|
+
if (err) return { ok: false, message: `invalid trigger: ${err}` };
|
|
271
|
+
const nr = computeNextRun(req.routine);
|
|
272
|
+
upsertRoutine({ ...req.routine, nextRun: nr?.toISOString() });
|
|
273
|
+
this.syncRelay(); // connect the relay if this routine introduced relay delivery
|
|
274
|
+
return { ok: true, message: `routine "${req.routine.name}" saved`, routines: loadRoutines() };
|
|
275
|
+
}
|
|
276
|
+
case "remove": {
|
|
277
|
+
const removed = removeRoutine(req.idOrName);
|
|
278
|
+
return removed
|
|
279
|
+
? { ok: true, message: `removed "${removed.name}"`, routines: loadRoutines() }
|
|
280
|
+
: { ok: false, message: `no routine "${req.idOrName}"` };
|
|
281
|
+
}
|
|
282
|
+
case "pause":
|
|
283
|
+
case "resume": {
|
|
284
|
+
const r = findRoutine(loadRoutines(), req.idOrName);
|
|
285
|
+
if (!r) return { ok: false, message: `no routine "${req.idOrName}"` };
|
|
286
|
+
const enabled = req.cmd === "resume";
|
|
287
|
+
const nr = enabled ? computeNextRun(r)?.toISOString() : undefined;
|
|
288
|
+
upsertRoutine({ ...r, enabled, nextRun: nr });
|
|
289
|
+
if (enabled) this.syncRelay();
|
|
290
|
+
return { ok: true, message: `${enabled ? "resumed" : "paused"} "${r.name}"`, routines: loadRoutines() };
|
|
291
|
+
}
|
|
292
|
+
case "run-now": {
|
|
293
|
+
const r = findRoutine(loadRoutines(), req.idOrName);
|
|
294
|
+
if (!r) return { ok: false, message: `no routine "${req.idOrName}"` };
|
|
295
|
+
// Fire in the background so the IPC caller isn't blocked on a long run.
|
|
296
|
+
void this.runRoutine(r);
|
|
297
|
+
return { ok: true, message: `running "${r.name}" now` };
|
|
298
|
+
}
|
|
299
|
+
case "reload":
|
|
300
|
+
this.primeSchedule();
|
|
301
|
+
this.syncRelay();
|
|
302
|
+
return { ok: true, message: "schedule reloaded", routines: loadRoutines() };
|
|
303
|
+
default:
|
|
304
|
+
return { ok: false, message: "unknown command" };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Entry point for `privateer daemon`.
|
|
310
|
+
export function runDaemon(): void {
|
|
311
|
+
const daemon = new Daemon();
|
|
312
|
+
daemon.start();
|
|
313
|
+
const shutdown = () => {
|
|
314
|
+
log("shutting down");
|
|
315
|
+
daemon.stop();
|
|
316
|
+
// Release this process's Privateer session so the daemon drops off the
|
|
317
|
+
// app's Linked Devices immediately (best effort, then exit regardless).
|
|
318
|
+
void revokeChildSession().finally(() => process.exit(0));
|
|
319
|
+
};
|
|
320
|
+
process.on("SIGINT", shutdown);
|
|
321
|
+
process.on("SIGTERM", shutdown);
|
|
322
|
+
}
|