shadok-ai 0.8.7 → 0.8.8
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/context/agents-skill/SKILL.md +137 -0
- package/context/agents-skill/pilotctl.mjs +502 -0
- package/context/agents-skill/test/auth.test.mjs +63 -0
- package/context/agents-skill/test/helpers.test.mjs +38 -0
- package/context/agents-skill/test/mgmt.test.mjs +156 -0
- package/context/agents-skill/test/mock-server.mjs +41 -0
- package/context/agents-skill/test/spawn.test.mjs +117 -0
- package/context/agents-skill/test/turns.test.mjs +162 -0
- package/dist/server.js +22 -0
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: shadok-ai-agents
|
|
3
|
+
description: Create and drive isolated Claude Code agents through the shadok-ai server (git worktrees, prompts, dialogs, diff). Use when the user wants to delegate a task to a shadok-ai agent, launch agents in parallel, or inspect/drive existing shadok-ai sessions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Driving shadok-ai agents
|
|
7
|
+
|
|
8
|
+
Every operation goes through the thin client shipped with this skill:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
node ~/.claude/skills/shadok-ai-agents/pilotctl.mjs <command> …
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Each command prints ONE JSON object on stdout (exit 1 + `{error}` on failure)
|
|
15
|
+
and automatically starts the shadok-ai server when it is not running (port
|
|
16
|
+
3789, or `$SHADOK_PORT`). Sessions stay visible in the web UI
|
|
17
|
+
(http://localhost:3789) — the user can follow along and step in.
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
| Command | Effect |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `spawn [--cwd DIR] [--worktree] [--profile NAME] [--resume ID] [--continue]` | creates an agent → `{sessionId, cwd, branch}`. `--worktree` isolates the agent in a git worktree (`~/.shadok-ai/worktrees/`, branch `shadok-ai/<tag>`). `--profile` gives it a role + its guardrails + its secrets (see below) |
|
|
24
|
+
| `prompt <id> "text" [--timeout s]` | sends a prompt, waits for the end of the turn → `{status:"answer", text, tools}` or `{status:"dialog", question, options, multi}` or `{status:"timeout", screen}` or `{status:"pace-blocked", reason}` |
|
|
25
|
+
| `dialog <id>` | queries the state → `{status:"idle"}` or the pending dialog |
|
|
26
|
+
| `choose <id> <n>` | single-select dialog: picks and commits option n |
|
|
27
|
+
| `toggle <id> <n>` then `confirm <id>` | multi-select dialog: check/uncheck then submit |
|
|
28
|
+
| `freetext <id> <n> "text"` | the "Type something" option: a free answer |
|
|
29
|
+
| `list [--cwd DIR]` | driven agents (local state + alive/dead) and resumable sessions |
|
|
30
|
+
| `diff <id>` | the agent's changes (git status + diff against the worktree's base) |
|
|
31
|
+
| `stop <id>` | ends the session (for ALL its clients) |
|
|
32
|
+
| `screen <id>` | raw TUI screen (debug) |
|
|
33
|
+
| `profile-prompt "<text>" [--name NAME] [--readonly]` | rewrites a profile's **system prompt**: your own by default; any of them (and creation with `--name`) under the lead profile |
|
|
34
|
+
|
|
35
|
+
## Choosing a profile (`--profile`)
|
|
36
|
+
|
|
37
|
+
A profile is a role applied at startup: system prompt, native permission
|
|
38
|
+
guardrails (e.g. git writes blocked), injected secrets, an optional model.
|
|
39
|
+
**Without `--profile` the agent starts as bare Claude** — no role, no guardrail,
|
|
40
|
+
no secrets.
|
|
41
|
+
|
|
42
|
+
The shipped profiles: `Shadok-Boss` (reads everything, delegates, read-only),
|
|
43
|
+
`Shadok-dev` (code, full access), `Shadok-Marketing` and `Shadok-Support`
|
|
44
|
+
(read-only). `pilotctl.mjs list` does not enumerate them — the list lives in the
|
|
45
|
+
UI's Profiles panel, or behind `GET /profiles`.
|
|
46
|
+
|
|
47
|
+
The profile is only applied to **new** sessions: with `--resume` or
|
|
48
|
+
`--continue`, the session keeps the one it already had.
|
|
49
|
+
|
|
50
|
+
## Growing your own role (`profile-prompt`)
|
|
51
|
+
|
|
52
|
+
You can rewrite your profile's **system prompt** — to record what you learned
|
|
53
|
+
about this repo, a convention nobody should rediscover, a trap to avoid. Under
|
|
54
|
+
the lead profile you can rewrite any prompt and **mint** a role (`--name`, plus
|
|
55
|
+
`--readonly` so it is born with git writes blocked).
|
|
56
|
+
|
|
57
|
+
What you **cannot** touch: `deny`, `allow`, `secrets`, `model`. Those are the
|
|
58
|
+
guardrails, they belong to the human and are edited from the web UI — a
|
|
59
|
+
read-only agent must not be able to grant itself git writes, nor a minted role
|
|
60
|
+
hand itself the vault's secrets. These fields are ignored here, not merely
|
|
61
|
+
refused.
|
|
62
|
+
|
|
63
|
+
Authorisation rests on `$SHADOK_SESSION_KEY`, injected into your env at startup
|
|
64
|
+
— the session id would not do, `/live` publishes it.
|
|
65
|
+
|
|
66
|
+
The prompt is passed to `claude` **at spawn**: a change takes effect at the
|
|
67
|
+
agent's next restart, not mid-session.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
node ~/.claude/skills/shadok-ai-agents/pilotctl.mjs profile-prompt "$(cat <<'TXT'
|
|
71
|
+
… the complete new prompt …
|
|
72
|
+
TXT
|
|
73
|
+
)"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Write the **whole** prompt: it replaces the old one, it does not add to it.
|
|
77
|
+
|
|
78
|
+
## Typical flow: delegating a task to an agent
|
|
79
|
+
|
|
80
|
+
1. `spawn --worktree --profile <role> --cwd <repo>` → note `sessionId` and `branch`;
|
|
81
|
+
2. `prompt <id> "<task>"` — launch it through Bash with **run_in_background**
|
|
82
|
+
(a turn can take several minutes) and read the JSON at the end;
|
|
83
|
+
3. if `status:"dialog"`: answer with `choose` (single) or `toggle`+`confirm`
|
|
84
|
+
(multi) or `freetext`, which in turn return `answer` or a new `dialog`;
|
|
85
|
+
4. if `status:"timeout"`: the turn CONTINUES server-side — do not resend the
|
|
86
|
+
prompt; check back later with `dialog <id>`;
|
|
87
|
+
4bis. if `status:"pace-blocked"`: NOTHING was sent — usage is above the quota's
|
|
88
|
+
ideal pace (`reason` spells it out). Do not insist in a loop; tell the user;
|
|
89
|
+
5. task finished: `diff <id>` and present the changes to the user. The
|
|
90
|
+
`shadok-ai/<tag>` branch and its worktree are NEVER merged or deleted
|
|
91
|
+
automatically — the user is the one who merges.
|
|
92
|
+
|
|
93
|
+
Parallel agents: repeat `spawn` (one id per agent), and launch the `prompt`
|
|
94
|
+
calls in the background simultaneously.
|
|
95
|
+
|
|
96
|
+
## You get told: your agents report back
|
|
97
|
+
|
|
98
|
+
An agent you spawn is registered as your **child**, automatically — nothing to
|
|
99
|
+
pass. You then receive a message when it:
|
|
100
|
+
|
|
101
|
+
- **finishes its turn** (with its own summary and a pointer to its `diff`);
|
|
102
|
+
- **blocks on a question** (the question, its options, and how to answer);
|
|
103
|
+
- **dies or times out** — otherwise you would wait forever for an agent that is
|
|
104
|
+
already gone.
|
|
105
|
+
|
|
106
|
+
These messages arrive prefixed `🤖 [agent]`. You are told about **your** children
|
|
107
|
+
and about no other channel.
|
|
108
|
+
|
|
109
|
+
Practical consequence: **do not loop to watch an agent any more.** Launch the
|
|
110
|
+
`prompt` in the background and move on — repeated polling costs a turn every
|
|
111
|
+
time, and the information comes to you.
|
|
112
|
+
|
|
113
|
+
`--parent none` spawns an agent deliberately unattached; `--parent <id>`
|
|
114
|
+
attaches it elsewhere.
|
|
115
|
+
|
|
116
|
+
## Guardrails
|
|
117
|
+
|
|
118
|
+
- NEVER `stop` a session this conversation did not create: it may belong to the
|
|
119
|
+
user in the web UI. `stop` ends the session for all its clients.
|
|
120
|
+
- Every agent consumes the Claude quota like an ordinary session. Do not
|
|
121
|
+
multiply agents without an explicit request from the user.
|
|
122
|
+
- `prompt` on a session whose turn is already running → the error "a response is
|
|
123
|
+
already in progress": wait with `dialog <id>`.
|
|
124
|
+
- If an agent seems stuck in a state the dialogs do not cover, look at
|
|
125
|
+
`screen <id>` (the equivalent of the UI's "engine room").
|
|
126
|
+
- To resume an existing session (`spawn --resume <id>`), always pass `--cwd`
|
|
127
|
+
with the session's directory (the server would otherwise fall back to its own
|
|
128
|
+
cwd); for an agent already being driven, the local state supplies that cwd
|
|
129
|
+
automatically.
|
|
130
|
+
|
|
131
|
+
## Mechanics (for debugging)
|
|
132
|
+
|
|
133
|
+
The server kills the claude process when its last WS client detaches; so
|
|
134
|
+
`pilotctl` keeps a small detached "holder" process per agent (the internal
|
|
135
|
+
`hold` command), restarted as needed by every command. Local state:
|
|
136
|
+
`~/.shadok-ai/pilotctl/<id>.json` (cwd, branch, baseSha, holderPid). Log of the
|
|
137
|
+
auto-started server: `~/.shadok-ai/pilotctl/server.log`.
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pilotctl — thin client for the shadok-ai web server. One-shot commands,
|
|
3
|
+
// JSON on stdout. See ./SKILL.md.
|
|
4
|
+
import { execFileSync, spawn as spawnChild } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import WebSocket from "ws";
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
// Only used to auto-start a server when none is reachable (a dev running pilotctl
|
|
13
|
+
// inside the repo). When this skill is seeded into ~/.claude/skills so ANY agent
|
|
14
|
+
// can pilot, REPO_ROOT is meaningless — ensureServer guards on it below.
|
|
15
|
+
export const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
|
16
|
+
|
|
17
|
+
export const port = () => Number(process.env.SHADOK_PORT ?? 3789);
|
|
18
|
+
export const httpBase = () => `http://localhost:${port()}`;
|
|
19
|
+
export const wsUrl = () => `ws://localhost:${port()}/ws`;
|
|
20
|
+
// When a GUI password is set, the server injects SHADOK_AUTH=sk_auth=<token>
|
|
21
|
+
// into every agent's env; loopback HTTP + WS calls must present it as the cookie
|
|
22
|
+
// or the password gate 401s them — exactly what secret.py / schedule.py and the
|
|
23
|
+
// Telegram bridge already do. Empty when no password is set → no change.
|
|
24
|
+
export const authHeaders = () => (process.env.SHADOK_AUTH ? { cookie: process.env.SHADOK_AUTH } : {});
|
|
25
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
26
|
+
|
|
27
|
+
export function stateDir() {
|
|
28
|
+
return process.env.SHADOK_STATE_DIR ?? path.join(os.homedir(), ".shadok-ai", "pilotctl");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readState(id) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(path.join(stateDir(), id + ".json"), "utf8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeState(id, obj) {
|
|
40
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
41
|
+
fs.writeFileSync(path.join(stateDir(), id + ".json"), JSON.stringify(obj, null, 2));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function deleteState(id) {
|
|
45
|
+
fs.rmSync(path.join(stateDir(), id + ".json"), { force: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function pidAlive(pid) {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseArgs(argv) {
|
|
58
|
+
const [cmd, ...rest] = argv;
|
|
59
|
+
const flags = {};
|
|
60
|
+
const pos = [];
|
|
61
|
+
for (let i = 0; i < rest.length; i++) {
|
|
62
|
+
const a = rest[i];
|
|
63
|
+
if (a === "--worktree" || a === "--continue" || a === "--readonly") flags[a.slice(2)] = true;
|
|
64
|
+
else if (a === "--cwd" || a === "--resume" || a === "--timeout" || a === "--profile" || a === "--parent") flags[a.slice(2)] = rest[++i];
|
|
65
|
+
else pos.push(a);
|
|
66
|
+
}
|
|
67
|
+
return { cmd, pos, flags };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function connect() {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const ws = new WebSocket(wsUrl(), { headers: authHeaders() });
|
|
73
|
+
ws.once("open", () => resolve(ws));
|
|
74
|
+
ws.once("error", reject);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Attaches to (or starts) a session; resolves once the server says `ready`.
|
|
79
|
+
export async function openSession(startMsg) {
|
|
80
|
+
const ws = await connect();
|
|
81
|
+
const listeners = new Set();
|
|
82
|
+
const state = { lastScreen: "", busy: false, ready: null };
|
|
83
|
+
ws.on("message", (raw) => {
|
|
84
|
+
let msg;
|
|
85
|
+
try {
|
|
86
|
+
msg = JSON.parse(String(raw));
|
|
87
|
+
} catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (msg.type === "screen") state.lastScreen = msg.text;
|
|
91
|
+
if (msg.type === "working") state.busy = true;
|
|
92
|
+
if (msg.type === "turn-done" || msg.type === "dialog") state.busy = false;
|
|
93
|
+
for (const l of [...listeners]) l(msg);
|
|
94
|
+
});
|
|
95
|
+
ws.on("close", () => {
|
|
96
|
+
for (const l of [...listeners]) l({ type: "socket-closed" });
|
|
97
|
+
});
|
|
98
|
+
const client = {
|
|
99
|
+
ws,
|
|
100
|
+
state,
|
|
101
|
+
send: (m) => ws.send(JSON.stringify(m)),
|
|
102
|
+
on: (l) => listeners.add(l),
|
|
103
|
+
off: (l) => listeners.delete(l),
|
|
104
|
+
waitFor: (types, timeoutMs) =>
|
|
105
|
+
new Promise((resolve) => {
|
|
106
|
+
const timer = timeoutMs
|
|
107
|
+
? setTimeout(() => {
|
|
108
|
+
listeners.delete(l);
|
|
109
|
+
resolve({ type: "timeout" });
|
|
110
|
+
}, timeoutMs)
|
|
111
|
+
: null;
|
|
112
|
+
const l = (msg) => {
|
|
113
|
+
if (!types.includes(msg.type)) return;
|
|
114
|
+
if (timer) clearTimeout(timer);
|
|
115
|
+
listeners.delete(l);
|
|
116
|
+
resolve(msg);
|
|
117
|
+
};
|
|
118
|
+
listeners.add(l);
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
client.send({ type: "start", ...startMsg });
|
|
122
|
+
const ready = await client.waitFor(["ready", "error"], 90_000);
|
|
123
|
+
if (ready.type !== "ready") {
|
|
124
|
+
ws.close();
|
|
125
|
+
throw new Error(ready.message ?? "timeout waiting for ready");
|
|
126
|
+
}
|
|
127
|
+
state.ready = ready;
|
|
128
|
+
return client;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Accumulates streamed content until the turn ends (or a dialog suspends it).
|
|
132
|
+
export async function collectTurn(client, timeoutMs) {
|
|
133
|
+
const texts = [];
|
|
134
|
+
const tools = [];
|
|
135
|
+
const collector = (msg) => {
|
|
136
|
+
if (msg.type === "stream-text") texts.push(msg.text);
|
|
137
|
+
else if (msg.type === "stream-tool") tools.push({ name: msg.name, summary: msg.summary });
|
|
138
|
+
};
|
|
139
|
+
client.on(collector);
|
|
140
|
+
const end = await client.waitFor(
|
|
141
|
+
// "pace-blocked" is a refusal, not a turn: the server sent nothing to the
|
|
142
|
+
// TUI, so no turn-done will ever follow. Without it here the call would sit
|
|
143
|
+
// out the whole timeout and report an indistinguishable {status:"timeout"}.
|
|
144
|
+
["dialog", "turn-done", "exited", "stopped", "error", "pace-blocked", "socket-closed"],
|
|
145
|
+
timeoutMs,
|
|
146
|
+
);
|
|
147
|
+
client.off(collector);
|
|
148
|
+
const text = texts.join("\n\n");
|
|
149
|
+
if (end.type === "turn-done") return { status: "answer", text, tools };
|
|
150
|
+
if (end.type === "dialog")
|
|
151
|
+
return { status: "dialog", question: end.question, options: end.options, multi: end.multi, text };
|
|
152
|
+
if (end.type === "pace-blocked")
|
|
153
|
+
return { status: "pace-blocked", reason: end.reason ?? null, text };
|
|
154
|
+
if (end.type === "timeout") return { status: "timeout", screen: client.state.lastScreen, text };
|
|
155
|
+
if (end.type === "error") return { status: "error", error: end.message };
|
|
156
|
+
return { status: "exited", code: end.code ?? null, text };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function serverUp() {
|
|
160
|
+
try {
|
|
161
|
+
const r = await fetch(`${httpBase()}/sessions?cwd=${encodeURIComponent(os.homedir())}`, {
|
|
162
|
+
signal: AbortSignal.timeout(1500),
|
|
163
|
+
headers: authHeaders(),
|
|
164
|
+
});
|
|
165
|
+
return r.ok;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function ensureServer() {
|
|
172
|
+
if (await serverUp()) return;
|
|
173
|
+
if (process.env.SHADOK_NO_AUTOSTART)
|
|
174
|
+
throw new Error(`shadok-ai server unreachable on :${port()}`);
|
|
175
|
+
// Auto-start only works from a real repo checkout. Globally seeded, REPO_ROOT
|
|
176
|
+
// is not a repo — but a piloted agent always has a running server, so this
|
|
177
|
+
// path is only a standalone dev's. Fail clearly rather than `npm build`-ing $HOME.
|
|
178
|
+
const dist = path.join(REPO_ROOT, "dist", "server.js");
|
|
179
|
+
if (!fs.existsSync(dist)) {
|
|
180
|
+
if (!fs.existsSync(path.join(REPO_ROOT, "package.json")))
|
|
181
|
+
throw new Error(`shadok-ai server unreachable on :${port()} (and no repo to auto-start one — start the server first)`);
|
|
182
|
+
execFileSync("npm", ["run", "build"], { cwd: REPO_ROOT, stdio: "ignore" });
|
|
183
|
+
}
|
|
184
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
185
|
+
const logPath = path.join(stateDir(), "server.log");
|
|
186
|
+
const log = fs.openSync(logPath, "a");
|
|
187
|
+
const child = spawnChild(process.execPath, [dist], {
|
|
188
|
+
cwd: REPO_ROOT,
|
|
189
|
+
detached: true,
|
|
190
|
+
stdio: ["ignore", log, log],
|
|
191
|
+
env: { ...process.env, PORT: String(port()) },
|
|
192
|
+
});
|
|
193
|
+
child.unref();
|
|
194
|
+
const deadline = Date.now() + 15_000;
|
|
195
|
+
while (Date.now() < deadline) {
|
|
196
|
+
if (await serverUp()) return;
|
|
197
|
+
await sleep(300);
|
|
198
|
+
}
|
|
199
|
+
throw new Error(`shadok-ai server did not come up on :${port()} (log: ${logPath})`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The server kills the claude process when its last WS client detaches, so a
|
|
203
|
+
// detached "hold" process keeps one attachment open per piloted agent.
|
|
204
|
+
export async function ensureHolder(id, cwd) {
|
|
205
|
+
if (process.env.SHADOK_NO_HOLDER) return;
|
|
206
|
+
const st = readState(id);
|
|
207
|
+
if (st?.holderPid && pidAlive(st.holderPid)) return;
|
|
208
|
+
const self = fileURLToPath(import.meta.url);
|
|
209
|
+
const args = [self, "hold", id];
|
|
210
|
+
if (cwd) args.push(cwd);
|
|
211
|
+
const child = spawnChild(process.execPath, args, {
|
|
212
|
+
detached: true,
|
|
213
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
214
|
+
env: process.env,
|
|
215
|
+
});
|
|
216
|
+
try {
|
|
217
|
+
await new Promise((resolve, reject) => {
|
|
218
|
+
const t = setTimeout(() => reject(new Error("holder failed to attach within 90s")), 90_000);
|
|
219
|
+
child.stdout.on("data", (d) => {
|
|
220
|
+
if (String(d).includes("attached")) {
|
|
221
|
+
clearTimeout(t);
|
|
222
|
+
resolve();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
child.once("exit", () => {
|
|
226
|
+
clearTimeout(t);
|
|
227
|
+
reject(new Error("holder exited before attaching"));
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
} finally {
|
|
231
|
+
child.unref();
|
|
232
|
+
}
|
|
233
|
+
writeState(id, { ...(readState(id) ?? { sessionId: id }), cwd: cwd ?? null, holderPid: child.pid });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The `start` payload for a spawn. Pure, so the parent-linking rule is testable
|
|
238
|
+
* without a server.
|
|
239
|
+
*
|
|
240
|
+
* `SHADOK_SESSION_ID` is set by the server on every piloted session, so an agent
|
|
241
|
+
* that spawns another is identified with no extra plumbing and nothing to
|
|
242
|
+
* configure — and it is then the only channel told when that child finishes,
|
|
243
|
+
* blocks or dies. A human shell has no such variable and therefore creates a
|
|
244
|
+
* root.
|
|
245
|
+
*/
|
|
246
|
+
export function buildStartMsg(flags, env = process.env) {
|
|
247
|
+
const startMsg = {};
|
|
248
|
+
if (flags.cwd) startMsg.cwd = flags.cwd;
|
|
249
|
+
if (flags.worktree) startMsg.worktree = true;
|
|
250
|
+
if (flags.resume) startMsg.resume = flags.resume;
|
|
251
|
+
if (flags.continue) startMsg.continue = true;
|
|
252
|
+
// With no profile the delegated agent starts as bare Claude: no role, no
|
|
253
|
+
// guardrail, no secrets. The server only applies it to new sessions (not on a
|
|
254
|
+
// resume).
|
|
255
|
+
if (flags.profile) startMsg.profile = flags.profile;
|
|
256
|
+
// "none" is the escape hatch: spawn something deliberately unlinked.
|
|
257
|
+
if (flags.parent === "none") startMsg.parent = null;
|
|
258
|
+
else if (flags.parent) startMsg.parent = flags.parent;
|
|
259
|
+
else if (env.SHADOK_SESSION_ID) startMsg.parent = env.SHADOK_SESSION_ID;
|
|
260
|
+
return startMsg;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function cmdSpawn(flags) {
|
|
264
|
+
await ensureServer();
|
|
265
|
+
const startMsg = buildStartMsg(flags);
|
|
266
|
+
const client = await openSession(startMsg);
|
|
267
|
+
const { sessionId, cwd, branch } = client.state.ready;
|
|
268
|
+
let baseSha = null;
|
|
269
|
+
if (branch) {
|
|
270
|
+
try {
|
|
271
|
+
baseSha = execFileSync("git", ["-C", cwd, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
const prev = readState(sessionId) ?? {};
|
|
275
|
+
writeState(sessionId, {
|
|
276
|
+
...prev,
|
|
277
|
+
sessionId,
|
|
278
|
+
cwd,
|
|
279
|
+
branch: branch ?? prev.branch ?? null,
|
|
280
|
+
baseSha: baseSha ?? prev.baseSha ?? null,
|
|
281
|
+
holderPid: prev.holderPid ?? null,
|
|
282
|
+
});
|
|
283
|
+
await ensureHolder(sessionId, cwd);
|
|
284
|
+
client.ws.close();
|
|
285
|
+
return { sessionId, cwd, branch: branch ?? null };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Internal command: stays attached until the session ends.
|
|
289
|
+
async function cmdHold(id, cwd) {
|
|
290
|
+
await openSession({ resume: id, cwd }).then((client) => {
|
|
291
|
+
process.stdout.write("attached\n");
|
|
292
|
+
client.on((msg) => {
|
|
293
|
+
if (msg.type === "stopped") deleteState(id);
|
|
294
|
+
if (["stopped", "exited", "socket-closed"].includes(msg.type)) process.exit(0);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
return new Promise(() => {}); // never resolves; the WS keeps the loop alive
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Attaches to a piloted session and sends one protocol message, then waits
|
|
301
|
+
// for the outcome of the turn (answer, dialog, timeout…).
|
|
302
|
+
async function cmdTurn(id, msg, flags) {
|
|
303
|
+
await ensureServer();
|
|
304
|
+
const st = readState(id);
|
|
305
|
+
const cwd = flags.cwd ?? st?.cwd ?? undefined;
|
|
306
|
+
await ensureHolder(id, cwd);
|
|
307
|
+
const client = await openSession({ resume: id, cwd });
|
|
308
|
+
client.send(msg);
|
|
309
|
+
const result = await collectTurn(client, Number(flags.timeout ?? 600) * 1000);
|
|
310
|
+
client.ws.close();
|
|
311
|
+
if (result.status === "error") throw new Error(result.error);
|
|
312
|
+
return { ...result, sessionId: id };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function cmdDialog(id, flags) {
|
|
316
|
+
// `settle` is silently ignored server-side while a turn is in flight, and
|
|
317
|
+
// triggers a dialog/turn-done broadcast when idle — safe to always send.
|
|
318
|
+
const r = await cmdTurn(id, { type: "settle" }, flags);
|
|
319
|
+
if (r.status === "answer") return { status: "idle", sessionId: id, text: r.text };
|
|
320
|
+
return r;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function cmdList(flags) {
|
|
324
|
+
await ensureServer();
|
|
325
|
+
const cwd = flags.cwd ?? process.cwd();
|
|
326
|
+
const r = await fetch(`${httpBase()}/sessions?cwd=${encodeURIComponent(cwd)}`, { headers: authHeaders() });
|
|
327
|
+
const resumable = await r.json();
|
|
328
|
+
const dir = stateDir();
|
|
329
|
+
const agents = !fs.existsSync(dir)
|
|
330
|
+
? []
|
|
331
|
+
: fs
|
|
332
|
+
.readdirSync(dir)
|
|
333
|
+
.filter((f) => f.endsWith(".json"))
|
|
334
|
+
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")))
|
|
335
|
+
.map((s) => ({ ...s, live: !!(s.holderPid && pidAlive(s.holderPid)) }));
|
|
336
|
+
return { agents, resumable };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function cmdDiff(id) {
|
|
340
|
+
await ensureServer();
|
|
341
|
+
const r = await fetch(`${httpBase()}/diff?session=${encodeURIComponent(id)}`, { headers: authHeaders() });
|
|
342
|
+
const body = await r.json();
|
|
343
|
+
if (!body.error) return body;
|
|
344
|
+
// Session no longer live server-side: diff the worktree locally against
|
|
345
|
+
// the baseSha recorded at spawn time.
|
|
346
|
+
const st = readState(id);
|
|
347
|
+
if (!st?.cwd) throw new Error(`no live session and no local state for ${id}`);
|
|
348
|
+
const git = (args) =>
|
|
349
|
+
execFileSync("git", ["-C", st.cwd, ...args], { encoding: "utf8" }).trimEnd();
|
|
350
|
+
let diff = git(["diff", st.baseSha ?? "HEAD"]);
|
|
351
|
+
// Include untracked files (diff doesn't show them) — mirrors gitDiff in src/worktree.ts.
|
|
352
|
+
const untracked = git(["ls-files", "--others", "--exclude-standard"])
|
|
353
|
+
.split("\n")
|
|
354
|
+
.filter(Boolean);
|
|
355
|
+
if (untracked.length) {
|
|
356
|
+
const shown = untracked.map((f) => {
|
|
357
|
+
try {
|
|
358
|
+
return git(["diff", "--no-index", "/dev/null", f]);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
// --no-index exits non-zero when files differ; its stdout has the diff.
|
|
361
|
+
return e?.stdout ? String(e.stdout).trimEnd() : `+++ ${f} (untracked)`;
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
diff = [diff, ...shown].filter(Boolean).join("\n");
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
status: git(["status", "--short"]),
|
|
368
|
+
diff,
|
|
369
|
+
branch: st.branch ?? null,
|
|
370
|
+
fallback: true,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function cmdStop(id) {
|
|
375
|
+
await ensureServer();
|
|
376
|
+
const st = readState(id);
|
|
377
|
+
const holderLive = !!(st?.holderPid && pidAlive(st.holderPid));
|
|
378
|
+
if (!holderLive) {
|
|
379
|
+
// No holder → we never kept this session alive; don't resurrect it just
|
|
380
|
+
// to stop it (it may belong to a browser client). Clear local state only.
|
|
381
|
+
deleteState(id);
|
|
382
|
+
return { stopped: false, sessionId: id, note: "no live holder; local state cleared" };
|
|
383
|
+
}
|
|
384
|
+
let client;
|
|
385
|
+
try {
|
|
386
|
+
client = await openSession({ resume: id, cwd: st?.cwd ?? undefined });
|
|
387
|
+
} catch (err) {
|
|
388
|
+
// Holder pid is alive but session is dead (zombie holder, dead server session).
|
|
389
|
+
// Kill the holder and clean up state.
|
|
390
|
+
if (st.holderPid !== process.pid && pidAlive(st.holderPid)) {
|
|
391
|
+
try {
|
|
392
|
+
process.kill(st.holderPid);
|
|
393
|
+
} catch {}
|
|
394
|
+
}
|
|
395
|
+
deleteState(id);
|
|
396
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
397
|
+
return { stopped: false, sessionId: id, note: `session not reattachable (${msg}); holder killed and local state cleared` };
|
|
398
|
+
}
|
|
399
|
+
client.send({ type: "stop" });
|
|
400
|
+
const result = await client.waitFor(["stopped", "socket-closed"], 30_000);
|
|
401
|
+
client.ws.close();
|
|
402
|
+
if (result.type === "timeout") {
|
|
403
|
+
// Stop was not confirmed within 30s; don't kill holder or delete state (leave for retry).
|
|
404
|
+
return { stopped: false, sessionId: id, note: "stop not confirmed within 30s; session may still be running" };
|
|
405
|
+
}
|
|
406
|
+
if (st.holderPid !== process.pid && pidAlive(st.holderPid)) {
|
|
407
|
+
try {
|
|
408
|
+
process.kill(st.holderPid);
|
|
409
|
+
} catch {}
|
|
410
|
+
}
|
|
411
|
+
deleteState(id);
|
|
412
|
+
return { stopped: true, sessionId: id };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Rewrite a profile's SYSTEM PROMPT — your own by default, any one if you run
|
|
417
|
+
* under the lead profile (which may also mint a new role with --name/--readonly).
|
|
418
|
+
*
|
|
419
|
+
* Guardrails (deny/allow/secrets/model) are NOT touchable here by design: they
|
|
420
|
+
* are the human's, edited from the web UI. Authorization is SHADOK_SESSION_KEY,
|
|
421
|
+
* injected into your env at spawn — the session id would not do, it is public.
|
|
422
|
+
*
|
|
423
|
+
* The prompt is passed to claude at spawn, so a change lands at the agent's
|
|
424
|
+
* next restart, not mid-session.
|
|
425
|
+
*/
|
|
426
|
+
async function cmdProfilePrompt(text, flags) {
|
|
427
|
+
const key = process.env.SHADOK_SESSION_KEY;
|
|
428
|
+
if (!key) throw new Error("no SHADOK_SESSION_KEY in env — not running as a piloted agent");
|
|
429
|
+
if (!text || !text.trim()) throw new Error("a system prompt is required");
|
|
430
|
+
const body = { key, systemPrompt: text };
|
|
431
|
+
if (flags.name) body.name = flags.name;
|
|
432
|
+
if (flags.readonly) body.readOnly = true;
|
|
433
|
+
const r = await fetch(`${httpBase()}/profiles/prompt`, {
|
|
434
|
+
method: "PUT",
|
|
435
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
436
|
+
body: JSON.stringify(body),
|
|
437
|
+
});
|
|
438
|
+
const out = await r.json();
|
|
439
|
+
if (!r.ok) throw new Error(out.error ?? `HTTP ${r.status}`);
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function cmdScreen(id, flags) {
|
|
444
|
+
await ensureServer();
|
|
445
|
+
const st = readState(id);
|
|
446
|
+
const cwd = flags.cwd ?? st?.cwd ?? undefined;
|
|
447
|
+
await ensureHolder(id, cwd);
|
|
448
|
+
const client = await openSession({ resume: id, cwd });
|
|
449
|
+
if (!client.state.lastScreen) await client.waitFor(["screen"], 5000);
|
|
450
|
+
client.ws.close();
|
|
451
|
+
return { sessionId: id, screen: client.state.lastScreen };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const HELP =
|
|
455
|
+
"usage: pilotctl <spawn|prompt|dialog|choose|toggle|confirm|freetext|list|diff|stop|screen|profile-prompt> …";
|
|
456
|
+
|
|
457
|
+
export async function run(argv) {
|
|
458
|
+
const { cmd, pos, flags } = parseArgs(argv);
|
|
459
|
+
switch (cmd) {
|
|
460
|
+
case "spawn":
|
|
461
|
+
return cmdSpawn(flags);
|
|
462
|
+
case "hold":
|
|
463
|
+
return cmdHold(pos[0], pos[1]);
|
|
464
|
+
case "prompt":
|
|
465
|
+
return cmdTurn(pos[0], { type: "prompt", text: pos[1] }, flags);
|
|
466
|
+
case "dialog":
|
|
467
|
+
return cmdDialog(pos[0], flags);
|
|
468
|
+
case "choose":
|
|
469
|
+
return cmdTurn(pos[0], { type: "choose", n: Number(pos[1]) }, flags);
|
|
470
|
+
case "toggle":
|
|
471
|
+
return cmdTurn(pos[0], { type: "toggle", n: Number(pos[1]) }, flags);
|
|
472
|
+
case "confirm":
|
|
473
|
+
return cmdTurn(pos[0], { type: "confirm" }, flags);
|
|
474
|
+
case "freetext":
|
|
475
|
+
return cmdTurn(pos[0], { type: "freetext", n: Number(pos[1]), text: pos[2] }, flags);
|
|
476
|
+
case "list":
|
|
477
|
+
return cmdList(flags);
|
|
478
|
+
case "diff":
|
|
479
|
+
return cmdDiff(pos[0]);
|
|
480
|
+
case "stop":
|
|
481
|
+
return cmdStop(pos[0]);
|
|
482
|
+
case "screen":
|
|
483
|
+
return cmdScreen(pos[0], flags);
|
|
484
|
+
case "profile-prompt":
|
|
485
|
+
return cmdProfilePrompt(pos[0], flags);
|
|
486
|
+
default:
|
|
487
|
+
throw new Error(HELP);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// CLI entry point — not triggered when imported by tests.
|
|
492
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
493
|
+
run(process.argv.slice(2))
|
|
494
|
+
.then((r) => {
|
|
495
|
+
console.log(JSON.stringify(r));
|
|
496
|
+
process.exit(r?.error || r?.status === "error" ? 1 : 0);
|
|
497
|
+
})
|
|
498
|
+
.catch((e) => {
|
|
499
|
+
console.log(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
500
|
+
process.exit(1);
|
|
501
|
+
});
|
|
502
|
+
}
|