@soft-boy/agentschema 0.1.0 → 0.1.2
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 +115 -2
- package/bin/agentschema.js +191 -1
- package/package.json +10 -2
- package/src/chat.js +135 -0
- package/src/check.js +107 -0
- package/src/delete.js +25 -0
- package/src/deploy.js +143 -0
- package/src/init.js +186 -0
- package/src/instance.js +78 -0
- package/src/list.js +49 -0
- package/src/lock.js +34 -0
- package/src/session.js +129 -0
package/README.md
CHANGED
|
@@ -1,20 +1,133 @@
|
|
|
1
1
|
# agentschema
|
|
2
2
|
|
|
3
|
+
Define an AI-Agent as a folder of Markdown files.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
my-agent/
|
|
7
|
+
├── agent.md # the agent
|
|
8
|
+
└── env.md # the environment it runs in
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Status:** v0.1 draft.
|
|
12
|
+
|
|
13
|
+
## Philosophy
|
|
14
|
+
|
|
15
|
+
1. **One folder, one agent.** A folder defines one agent and the one environment it runs in. Sessions, credentials, mounted files and repositories, and schedules are supplied by whatever runs the agent.
|
|
16
|
+
2. **Everything is a Markdown file.** An agent is plain files you can read, diff, review and version like any other code. Structured settings go in YAML frontmatter, and prose the model reads goes in the Markdown body.
|
|
17
|
+
3. **Inspired by Claude Managed Agents, not tied to it.** The fields follow the Claude Managed Agents schema because it's a well-documented vocabulary for agents. The format itself assumes no particular infrastructure, so any runtime that can read the files can run the agent.
|
|
18
|
+
|
|
3
19
|
## Install
|
|
4
20
|
|
|
5
21
|
```sh
|
|
6
22
|
npm install -g @soft-boy/agentschema
|
|
7
23
|
```
|
|
8
24
|
|
|
25
|
+
## Specification
|
|
26
|
+
|
|
27
|
+
### Files
|
|
28
|
+
|
|
29
|
+
An instance is a folder of Markdown files.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
my-agent/
|
|
33
|
+
├── agent.md # the agent
|
|
34
|
+
└── env.md # the environment it runs in
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### `agent.md`
|
|
38
|
+
Field reference: [Agent setup](https://platform.claude.com/docs/en/managed-agents/agent-setup).
|
|
39
|
+
|
|
40
|
+
- **Frontmatter** uses the fields of a Claude Managed Agents agent (the `POST /v1/agents` request body), written as YAML. `name` and `model` are required. Every other field (`description`, `tools`, `mcp_servers`, `skills`, `multiagent`, `metadata`, …) is optional and uses the same names and shapes.
|
|
41
|
+
- **Body** is the agent's system prompt, sent as `system` exactly as written (leading and trailing whitespace trimmed). An empty body means no system prompt.
|
|
42
|
+
- Setting `system` in the frontmatter is an error, because the body is the system prompt.
|
|
43
|
+
|
|
44
|
+
### `env.md`
|
|
45
|
+
Field reference: [Environments](https://platform.claude.com/docs/en/managed-agents/environments).
|
|
46
|
+
|
|
47
|
+
- **Frontmatter** uses the fields of a Claude Managed Agents environment (the `POST /v1/environments` request body), written as YAML: `name`, `config`, and any other field the schema defines.
|
|
48
|
+
- **Body** is a note for people reading the folder. It is not sent anywhere.
|
|
49
|
+
|
|
9
50
|
## Usage
|
|
10
51
|
|
|
11
52
|
```sh
|
|
12
|
-
agentschema
|
|
53
|
+
agentschema init <name> # scaffold a new agent folder
|
|
54
|
+
agentschema check [dir] # validate the folder, no network
|
|
55
|
+
agentschema deploy [dir] # create or update the agent and environment
|
|
56
|
+
agentschema run [dir] # chat with the deployed agent in the terminal
|
|
57
|
+
agentschema list # list the agents in your workspace
|
|
58
|
+
agentschema delete <agent_id> # archive an agent
|
|
59
|
+
agentschema sessions list # list the sessions in your workspace
|
|
60
|
+
agentschema sessions delete <id> # delete a session
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`dir` defaults to the current directory.
|
|
64
|
+
|
|
65
|
+
**`init`** creates the folder `<name>` with an `agent.md` and an `env.md` that pass `check`. It asks for:
|
|
66
|
+
- the agent's name and model
|
|
67
|
+
- its system prompt
|
|
68
|
+
- whether it gets the built-in tools, and how tool calls are approved (`auto`, `always_allow` or `always_ask`)
|
|
69
|
+
- the environment's name and networking
|
|
70
|
+
|
|
71
|
+
Press Enter to keep the default shown, or pass `--yes` to skip the questions. `init` won't write into a folder that already has files in it.
|
|
72
|
+
|
|
73
|
+
**`check`** reads both files and reports every problem it finds:
|
|
74
|
+
- a missing file, or a name that isn't lowercase
|
|
75
|
+
- frontmatter that is missing, unclosed, or invalid YAML
|
|
76
|
+
- a missing `name` or `model`, or a `system` key in `agent.md`'s frontmatter
|
|
77
|
+
- values over the API's limits
|
|
78
|
+
- anything that looks like an API key or token
|
|
79
|
+
|
|
80
|
+
Unknown fields are only warnings, because they're sent to the API as written. `check` exits with status 1 when there are errors.
|
|
81
|
+
|
|
82
|
+
**`deploy`** runs `check` first, then creates or updates the environment and the agent, and records their IDs in `claude-lock.json` in the folder. Commit that file.
|
|
83
|
+
- **Later runs update the same resources.** A file that hasn't changed since the last deploy is skipped. Each change to `agent.md` adds a new version of the agent, and sessions already running keep the version they started on.
|
|
84
|
+
- **The files are the source of truth.** A field you delete from a file is cleared on the next deploy, rather than left at its old value.
|
|
85
|
+
- **Changes made elsewhere aren't overwritten silently.** If the agent was changed outside the folder since the last deploy, `deploy` stops; `--force` overwrites it.
|
|
86
|
+
|
|
87
|
+
`claude-lock.json` uses the same layout as `ant apply`'s, so `deploy` updates resources that `ant apply` created. The two tools hash files differently, though, so the first `deploy` after an `ant apply` updates both resources even if nothing changed.
|
|
88
|
+
|
|
89
|
+
**`run`** starts a session on the agent and environment in `claude-lock.json`, then opens a chat in the terminal. Deploy first; if you've edited the files since, `run` warns you and still uses the deployed version.
|
|
90
|
+
- **Messages:** type a message and press Enter. The agent's replies stream in, with a one-line summary of each tool call.
|
|
91
|
+
- **Approvals:** when a tool call needs approval (`always_ask`, or `auto` when the server can't decide), `run` asks you. Answer `y` to allow it, or type a reason to deny it; the agent sees the reason.
|
|
92
|
+
- **Custom tools:** `run` shows the call's input and asks you to type the result.
|
|
93
|
+
- **Interrupting:** Ctrl+C interrupts the agent mid-turn, and a second Ctrl+C quits. Ctrl+D quits at the prompt.
|
|
94
|
+
- **Resuming:** quitting leaves the session in place, and `run` prints a command to resume it: `agentschema run --resume sesn_…`.
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
agentschema run --resume sesn_… # resume an existing session
|
|
13
98
|
```
|
|
14
99
|
|
|
100
|
+
To run the agent from your own code, create a session with the agent and environment IDs from the lock file.
|
|
101
|
+
|
|
102
|
+
**`list`** prints every agent in your workspace, with its ID, latest version, model and when it was last updated. It includes agents that weren't deployed with agentschema, and leaves out archived ones.
|
|
103
|
+
|
|
104
|
+
**`sessions list`** prints every session in your workspace, newest first, with its status, the agent and version it runs, and its title. Pass an ID to `run --resume` to pick a session back up.
|
|
105
|
+
|
|
106
|
+
**`delete`** archives an agent. The API can't delete agents outright, so this is as close as it gets. An archived agent drops out of `list`, and the API has no way to unarchive it. If the agent is in a folder's `claude-lock.json`, remove its entry so the next `deploy` creates a new one.
|
|
107
|
+
|
|
108
|
+
**`sessions delete`** permanently deletes a session and its event history. A running session can't be deleted; wait for it to go idle, or interrupt it, first.
|
|
109
|
+
|
|
110
|
+
Both ask for confirmation first, showing the agent's name or the session's title. Pass `--yes` to skip that, for example in scripts; without a terminal to ask in, they refuse unless you pass `--yes`.
|
|
111
|
+
|
|
112
|
+
### Authentication
|
|
113
|
+
|
|
114
|
+
agentschema has no account setting of its own. Every command except `init` and `check` uses whichever Anthropic credentials are in your shell:
|
|
115
|
+
|
|
116
|
+
1. `ANTHROPIC_API_KEY`, if it's set
|
|
117
|
+
2. otherwise, the profile you signed in to with `ant auth login` (the active one, or the one `ANTHROPIC_PROFILE` names)
|
|
118
|
+
|
|
119
|
+
An API key belongs to one workspace, and the agent and environment are created in that workspace. Each person deploys with their own key, and to deploy somewhere else you switch the key:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
ANTHROPIC_API_KEY=sk-ant-… agentschema deploy my-agent
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`claude-lock.json` holds the IDs from one workspace. A committed lock works for everyone whose key is in that workspace, and they all update the same agent. If you deploy the folder from a key in a different workspace, `deploy` can't find those IDs and stops. In that case, deploy from a copy of the folder, or don't commit that lock file.
|
|
126
|
+
|
|
15
127
|
## Development
|
|
16
128
|
|
|
17
129
|
```sh
|
|
130
|
+
npm install
|
|
131
|
+
npm test # unit tests; deploy runs against a fake API client
|
|
18
132
|
npm link # makes `agentschema` available globally from this checkout
|
|
19
|
-
agentschema
|
|
20
133
|
```
|
package/bin/agentschema.js
CHANGED
|
@@ -1,3 +1,193 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
6
|
+
import { chat } from "../src/chat.js";
|
|
7
|
+
import { check } from "../src/check.js";
|
|
8
|
+
import { DeleteError, deleteAgent, deleteSession } from "../src/delete.js";
|
|
9
|
+
import { DeployError, deploy } from "../src/deploy.js";
|
|
10
|
+
import { InitError, init } from "../src/init.js";
|
|
11
|
+
import { listAgents, listSessions } from "../src/list.js";
|
|
12
|
+
import { SessionError, openSession } from "../src/session.js";
|
|
2
13
|
|
|
3
|
-
|
|
14
|
+
const USAGE = `Usage: agentschema <command> [dir] [options]
|
|
15
|
+
|
|
16
|
+
Commands:
|
|
17
|
+
init <name> Scaffold a new agent folder, asking a few questions
|
|
18
|
+
check [dir] Validate agent.md and env.md, without calling the API
|
|
19
|
+
deploy [dir] Create or update the agent and environment, and write claude-lock.json
|
|
20
|
+
run [dir] Chat with the deployed agent in a new session
|
|
21
|
+
list List the agents in your workspace
|
|
22
|
+
delete <agent_id> Archive an agent (agents can't be deleted outright)
|
|
23
|
+
sessions list List the sessions in your workspace, newest first
|
|
24
|
+
sessions delete <id> Delete a session and its history
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
-y, --yes (init) Skip the questions and use the defaults
|
|
28
|
+
(delete) Don't ask for confirmation
|
|
29
|
+
--dry-run (deploy) Show what would be created or updated, change nothing
|
|
30
|
+
--force (deploy) Update the agent even if it was changed outside this folder
|
|
31
|
+
--resume <id> (run) Resume an existing session (sesn_...) instead of starting one
|
|
32
|
+
-h, --help Show this help
|
|
33
|
+
-v, --version Show the version
|
|
34
|
+
|
|
35
|
+
dir defaults to the current directory. Every command but init and check authenticates with
|
|
36
|
+
ANTHROPIC_API_KEY, or with a profile from \`ant auth login\`.`;
|
|
37
|
+
|
|
38
|
+
const OPTIONS = {
|
|
39
|
+
yes: { type: "boolean", short: "y" },
|
|
40
|
+
"dry-run": { type: "boolean" },
|
|
41
|
+
force: { type: "boolean" },
|
|
42
|
+
resume: { type: "string" },
|
|
43
|
+
help: { type: "boolean", short: "h" },
|
|
44
|
+
version: { type: "boolean", short: "v" },
|
|
45
|
+
};
|
|
46
|
+
// For each command: how many positional arguments it takes, and which options.
|
|
47
|
+
const COMMANDS = {
|
|
48
|
+
init: { args: [1, 1], options: ["yes"] },
|
|
49
|
+
check: { args: [0, 1], options: [] },
|
|
50
|
+
deploy: { args: [0, 1], options: ["dry-run", "force"] },
|
|
51
|
+
run: { args: [0, 1], options: ["resume"] },
|
|
52
|
+
list: { args: [0, 0], options: [] },
|
|
53
|
+
delete: { args: [1, 1], options: ["yes"] },
|
|
54
|
+
"sessions list": { args: [0, 0], options: [] },
|
|
55
|
+
"sessions delete": { args: [1, 1], options: ["yes"] },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function main(argv) {
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error(`agentschema: ${err.message}\n\n${USAGE}`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const { values, positionals } = parsed;
|
|
67
|
+
let [command, ...args] = positionals;
|
|
68
|
+
if (command === "sessions") command = `sessions ${args.shift() ?? ""}`.trim();
|
|
69
|
+
|
|
70
|
+
if (values.version) {
|
|
71
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
72
|
+
console.log(pkg.version);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
if (!command || values.help) {
|
|
76
|
+
console.log(USAGE);
|
|
77
|
+
return command || values.help ? 0 : 1;
|
|
78
|
+
}
|
|
79
|
+
if (command.startsWith("sessions") && !(command in COMMANDS)) {
|
|
80
|
+
console.error(`agentschema: usage: agentschema sessions list | sessions delete <id>`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
if (!(command in COMMANDS)) {
|
|
84
|
+
console.error(`agentschema: unknown command "${command}"\n\n${USAGE}`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
const spec = COMMANDS[command];
|
|
88
|
+
const misplaced = Object.keys(values).filter((k) => !spec.options.includes(k));
|
|
89
|
+
if (args.length > spec.args[1] || misplaced.length > 0) {
|
|
90
|
+
const arg = args[spec.args[1]] ?? `--${misplaced[0]}`;
|
|
91
|
+
console.error(`agentschema: unexpected argument ${arg} for ${command}\n\n${USAGE}`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
if (args.length < spec.args[0]) {
|
|
95
|
+
const example = { init: "init my-agent", delete: "delete agent_…", "sessions delete": "sessions delete sesn_…" }[command];
|
|
96
|
+
console.error(`agentschema: ${command} needs an argument, e.g. agentschema ${example}`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const dir = args[0] ?? ".";
|
|
100
|
+
|
|
101
|
+
if (command === "init") {
|
|
102
|
+
try {
|
|
103
|
+
await init(dir, { yes: values.yes ?? false });
|
|
104
|
+
return 0;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (!(err instanceof InitError)) throw err;
|
|
107
|
+
console.error(`error: ${err.message}`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (command === "check") {
|
|
113
|
+
const { errors, warnings } = check(dir);
|
|
114
|
+
for (const w of warnings) console.warn(`warning: ${w}`);
|
|
115
|
+
for (const e of errors) console.error(`error: ${e}`);
|
|
116
|
+
if (errors.length > 0) return 1;
|
|
117
|
+
console.log(`${dir}: ok`);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
if (command === "deploy") {
|
|
123
|
+
for (const w of check(dir).warnings) console.warn(`warning: ${w}`);
|
|
124
|
+
const dryRun = values["dry-run"] ?? false;
|
|
125
|
+
await deploy(dir, {
|
|
126
|
+
client: makeClient(dryRun),
|
|
127
|
+
dryRun,
|
|
128
|
+
force: values.force ?? false,
|
|
129
|
+
log: (line) => console.log(line),
|
|
130
|
+
});
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (command === "list" || command === "sessions list") {
|
|
135
|
+
const lines = command === "list" ? await listAgents(makeClient(false)) : await listSessions(makeClient(false));
|
|
136
|
+
for (const line of lines) console.log(line);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (command === "delete" || command === "sessions delete") {
|
|
141
|
+
const remove = command === "delete" ? deleteAgent : deleteSession;
|
|
142
|
+
await remove(makeClient(false), args[0], {
|
|
143
|
+
confirm: values.yes ? async () => true : confirm,
|
|
144
|
+
log: (line) => console.log(line),
|
|
145
|
+
});
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (command === "run") {
|
|
150
|
+
const client = makeClient(false);
|
|
151
|
+
const { session, notes } = await openSession(dir, {
|
|
152
|
+
client,
|
|
153
|
+
sessionId: values.resume,
|
|
154
|
+
});
|
|
155
|
+
for (const note of notes) console.warn(`warning: ${note}`);
|
|
156
|
+
await chat(client, session, { agentName: session.agent?.name ?? "agent" });
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (err instanceof Anthropic.APIError) {
|
|
161
|
+
console.error(`error: ${err.status ?? "network error"}: ${err.error?.error?.message ?? err.message}`);
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
if (err instanceof DeployError || err instanceof DeleteError || err instanceof SessionError || err instanceof Anthropic.AnthropicError) {
|
|
165
|
+
console.error(`error: ${err.message}`);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Asks a yes/no question in the terminal; without one, there's no one to ask.
|
|
173
|
+
async function confirm(question) {
|
|
174
|
+
if (!process.stdin.isTTY) throw new DeleteError("not a terminal, so can't ask for confirmation; pass --yes");
|
|
175
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
176
|
+
try {
|
|
177
|
+
return /^(y|yes)$/i.test((await rl.question(`${question} [y/N] `)).trim());
|
|
178
|
+
} finally {
|
|
179
|
+
rl.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A dry run makes no API calls, so it works without credentials.
|
|
184
|
+
function makeClient(dryRun) {
|
|
185
|
+
try {
|
|
186
|
+
return new Anthropic();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (!dryRun) throw err;
|
|
189
|
+
return { baseURL: process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com" };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
process.exitCode = await main(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soft-boy/agentschema",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Markdown based AI agent schema",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"agentschema": "bin/agentschema.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"bin"
|
|
10
|
+
"bin",
|
|
11
|
+
"src"
|
|
11
12
|
],
|
|
12
13
|
"engines": {
|
|
13
14
|
"node": ">=18"
|
|
@@ -15,5 +16,12 @@
|
|
|
15
16
|
"license": "MIT",
|
|
16
17
|
"publishConfig": {
|
|
17
18
|
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@anthropic-ai/sdk": "^1.4.0",
|
|
22
|
+
"yaml": "^2.9.1"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/*.test.js"
|
|
18
26
|
}
|
|
19
27
|
}
|
package/src/chat.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { Conversation } from "./session.js";
|
|
3
|
+
|
|
4
|
+
const color = (code) => (text) => (process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
5
|
+
const dim = color(2);
|
|
6
|
+
const red = color(31);
|
|
7
|
+
const bold = color(1);
|
|
8
|
+
|
|
9
|
+
// Prints a session's events as a terminal transcript.
|
|
10
|
+
export function formatEvent(event) {
|
|
11
|
+
switch (event.type) {
|
|
12
|
+
case "agent.message":
|
|
13
|
+
return event.content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
14
|
+
case "agent.tool_use":
|
|
15
|
+
case "agent.mcp_tool_use": {
|
|
16
|
+
const name = event.type === "agent.mcp_tool_use" ? `${event.mcp_server_name}.${event.name}` : event.name;
|
|
17
|
+
const denied = event.evaluated_permission === "deny" ? red(" (denied)") : "";
|
|
18
|
+
return dim(`· ${name} ${summarize(event.input)}`) + denied;
|
|
19
|
+
}
|
|
20
|
+
case "agent.custom_tool_use":
|
|
21
|
+
return dim(`· ${event.name} ${summarize(event.input)}`);
|
|
22
|
+
case "agent.tool_result":
|
|
23
|
+
case "agent.mcp_tool_result":
|
|
24
|
+
if (!event.is_error) return null;
|
|
25
|
+
return red(` ↳ ${firstLine(event.content)}`);
|
|
26
|
+
case "session.error":
|
|
27
|
+
return red(`error: ${event.error?.message ?? event.error?.type ?? "unknown error"}`);
|
|
28
|
+
case "session.status_idle": {
|
|
29
|
+
const reason = event.stop_reason?.type;
|
|
30
|
+
if (reason === "retries_exhausted") return red("The agent gave up after repeated errors.");
|
|
31
|
+
if (reason === "budget_reached") return red("The session reached its budget and paused.");
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
case "session.status_terminated":
|
|
35
|
+
return dim("The session has ended.");
|
|
36
|
+
default:
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// One line describing a tool call's input: the command, path or query when there is one.
|
|
42
|
+
function summarize(input = {}) {
|
|
43
|
+
for (const key of ["command", "file_path", "path", "pattern", "url", "query"]) {
|
|
44
|
+
if (typeof input[key] === "string") return truncate(input[key]);
|
|
45
|
+
}
|
|
46
|
+
return truncate(JSON.stringify(input));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function firstLine(content = []) {
|
|
50
|
+
const text = content.find((b) => b.type === "text")?.text ?? "tool error";
|
|
51
|
+
return truncate(text.split("\n")[0]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const truncate = (s, n = 100) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
55
|
+
|
|
56
|
+
// Runs an interactive chat in the terminal until the user quits or the session ends.
|
|
57
|
+
export async function chat(client, session, { agentName, input = process.stdin, output = process.stdout }) {
|
|
58
|
+
const rl = createInterface({ input, output, terminal: input.isTTY });
|
|
59
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
60
|
+
let running = false;
|
|
61
|
+
let interrupted = false;
|
|
62
|
+
let quit = false;
|
|
63
|
+
|
|
64
|
+
const ask = async (prompt) => {
|
|
65
|
+
output.write(prompt);
|
|
66
|
+
const { value, done } = await lines.next();
|
|
67
|
+
if (done) quit = true;
|
|
68
|
+
return done ? null : value;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const ui = {
|
|
72
|
+
event(event) {
|
|
73
|
+
const text = formatEvent(event);
|
|
74
|
+
if (text) output.write(`${text}\n`);
|
|
75
|
+
},
|
|
76
|
+
async confirm(event) {
|
|
77
|
+
const answer = await ask(bold(`Allow ${event.name}? `) + dim("[y/N, or type why not] "));
|
|
78
|
+
if (answer && /^(y|yes)$/i.test(answer.trim())) return { result: "allow" };
|
|
79
|
+
const reason = answer?.trim();
|
|
80
|
+
return { result: "deny", deny_message: reason && !/^(n|no)$/i.test(reason) ? reason : undefined };
|
|
81
|
+
},
|
|
82
|
+
async customToolResult(event) {
|
|
83
|
+
output.write(dim(`${event.name} is a custom tool, so you supply its result.\n`));
|
|
84
|
+
output.write(dim(`input: ${JSON.stringify(event.input)}\n`));
|
|
85
|
+
return (await ask(bold("result> "))) ?? "The user didn't provide a result.";
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const conversation = new Conversation(client, session.id, ui);
|
|
90
|
+
let ended = false;
|
|
91
|
+
|
|
92
|
+
// Ctrl+C interrupts the agent while it works; a second Ctrl+C, or one at the prompt, quits.
|
|
93
|
+
rl.on("SIGINT", () => {
|
|
94
|
+
if (running && !interrupted) {
|
|
95
|
+
interrupted = true;
|
|
96
|
+
output.write(dim("\nInterrupting… (Ctrl+C again to quit)\n"));
|
|
97
|
+
conversation.interrupt().catch(() => {});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
quit = true;
|
|
101
|
+
conversation.close();
|
|
102
|
+
rl.close();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
output.write(dim(`${agentName} · session ${session.id}\nCtrl+C interrupts the agent, Ctrl+D quits.\n\n`));
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
while (!quit) {
|
|
109
|
+
const text = await ask(bold("> "));
|
|
110
|
+
if (text === null) break;
|
|
111
|
+
if (text.trim() === "") continue;
|
|
112
|
+
|
|
113
|
+
running = true;
|
|
114
|
+
interrupted = false;
|
|
115
|
+
let outcome;
|
|
116
|
+
try {
|
|
117
|
+
outcome = await conversation.send(text);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (quit) break;
|
|
120
|
+
throw err;
|
|
121
|
+
} finally {
|
|
122
|
+
running = false;
|
|
123
|
+
}
|
|
124
|
+
output.write("\n");
|
|
125
|
+
if (outcome === "terminated") {
|
|
126
|
+
ended = true;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
conversation.close();
|
|
132
|
+
rl.close();
|
|
133
|
+
}
|
|
134
|
+
if (!ended) output.write(dim(`Resume this conversation with: agentschema run --resume ${session.id}\n`));
|
|
135
|
+
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { AGENT_FILE, ENV_FILE, readInstance } from "./instance.js";
|
|
2
|
+
|
|
3
|
+
const AGENT_FIELDS = new Set([
|
|
4
|
+
"name", "model", "description", "tools", "mcp_servers", "skills",
|
|
5
|
+
"multiagent", "metadata", "execution_identity",
|
|
6
|
+
]);
|
|
7
|
+
const ENV_FIELDS = new Set(["name", "config", "description", "metadata", "scope"]);
|
|
8
|
+
|
|
9
|
+
const SECRET_PATTERNS = [
|
|
10
|
+
[/sk-ant-[A-Za-z0-9_-]{8,}/, "an Anthropic API key"],
|
|
11
|
+
[/\bghp_[A-Za-z0-9]{20,}/, "a GitHub token"],
|
|
12
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}/, "a GitHub token"],
|
|
13
|
+
[/\bxox[bpas]-[A-Za-z0-9-]{10,}/, "a Slack token"],
|
|
14
|
+
[/\bAKIA[0-9A-Z]{16}\b/, "an AWS access key"],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
18
|
+
|
|
19
|
+
// Validates an instance folder without calling the API.
|
|
20
|
+
// Returns { errors, warnings, instance }; instance is null when the files can't be read.
|
|
21
|
+
export function check(dir) {
|
|
22
|
+
const { agent, env, errors } = readInstance(dir);
|
|
23
|
+
const warnings = [];
|
|
24
|
+
|
|
25
|
+
if (agent) checkAgent(agent, errors, warnings);
|
|
26
|
+
if (env) checkEnv(env, errors, warnings);
|
|
27
|
+
for (const [file, parsed] of [[AGENT_FILE, agent], [ENV_FILE, env]]) {
|
|
28
|
+
if (!parsed) continue;
|
|
29
|
+
for (const [pattern, what] of SECRET_PATTERNS) {
|
|
30
|
+
if (pattern.test(parsed.raw)) errors.push(`${file}: looks like it contains ${what}; keep secrets out of these files`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { errors, warnings, instance: agent && env ? { agent, env } : null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkAgent({ data, body }, errors, warnings) {
|
|
38
|
+
const err = (msg) => errors.push(`${AGENT_FILE}: ${msg}`);
|
|
39
|
+
|
|
40
|
+
if (typeof data.name !== "string" || data.name.trim() === "") err("`name` is required");
|
|
41
|
+
else if (data.name.length > 256) err("`name` must be at most 256 characters");
|
|
42
|
+
|
|
43
|
+
if (data.model === undefined) err("`model` is required");
|
|
44
|
+
else if (isObject(data.model) ? typeof data.model.id !== "string" : typeof data.model !== "string") {
|
|
45
|
+
err("`model` must be a model ID, or a mapping with an `id`");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if ("system" in data) err("`system` is not allowed in frontmatter; the Markdown body is the system prompt");
|
|
49
|
+
if (body.length > 100_000) err(`system prompt is ${body.length} characters; the limit is 100,000`);
|
|
50
|
+
|
|
51
|
+
if (data.description !== undefined && data.description !== null) {
|
|
52
|
+
if (typeof data.description !== "string") err("`description` must be a string");
|
|
53
|
+
else if (data.description.length > 2048) err("`description` must be at most 2048 characters");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const [field, max] of [["tools", 256], ["mcp_servers", 20], ["skills", 500]]) {
|
|
57
|
+
if (data[field] === undefined) continue;
|
|
58
|
+
if (!Array.isArray(data[field])) err(`\`${field}\` must be a list`);
|
|
59
|
+
else if (data[field].length > max) err(`\`${field}\` has ${data[field].length} entries; the limit is ${max}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
checkMetadata(data.metadata, 16, err);
|
|
63
|
+
|
|
64
|
+
for (const key of Object.keys(data)) {
|
|
65
|
+
if (!AGENT_FIELDS.has(key) && key !== "system") {
|
|
66
|
+
warnings.push(`${AGENT_FILE}: unknown field \`${key}\` will be sent to the API as written`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkEnv({ data }, errors, warnings) {
|
|
72
|
+
const err = (msg) => errors.push(`${ENV_FILE}: ${msg}`);
|
|
73
|
+
|
|
74
|
+
if (typeof data.name !== "string" || data.name.trim() === "") err("`name` is required");
|
|
75
|
+
|
|
76
|
+
if (data.config !== undefined) {
|
|
77
|
+
const { config } = data;
|
|
78
|
+
if (!isObject(config)) err("`config` must be a mapping");
|
|
79
|
+
else {
|
|
80
|
+
if (!["cloud", "self_hosted"].includes(config.type)) err("`config.type` must be `cloud` or `self_hosted`");
|
|
81
|
+
const net = config.networking;
|
|
82
|
+
if (net !== undefined && !(isObject(net) && ["limited", "unrestricted"].includes(net.type))) {
|
|
83
|
+
err("`config.networking.type` must be `limited` or `unrestricted`");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (data.description !== undefined && data.description !== null && typeof data.description !== "string") {
|
|
89
|
+
err("`description` must be a string");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
checkMetadata(data.metadata, Infinity, err);
|
|
93
|
+
|
|
94
|
+
for (const key of Object.keys(data)) {
|
|
95
|
+
if (!ENV_FIELDS.has(key)) warnings.push(`${ENV_FILE}: unknown field \`${key}\` will be sent to the API as written`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function checkMetadata(metadata, maxKeys, err) {
|
|
100
|
+
if (metadata === undefined) return;
|
|
101
|
+
if (!isObject(metadata)) return err("`metadata` must be a mapping");
|
|
102
|
+
const keys = Object.keys(metadata);
|
|
103
|
+
if (keys.length > maxKeys) err(`\`metadata\` has ${keys.length} keys; the limit is ${maxKeys}`);
|
|
104
|
+
for (const key of keys) {
|
|
105
|
+
if (typeof metadata[key] !== "string") err(`\`metadata.${key}\` must be a string (quote numbers and booleans)`);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/delete.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export class DeleteError extends Error {}
|
|
2
|
+
|
|
3
|
+
// Agents can't be deleted, only archived: an archived agent drops out of `list`, and
|
|
4
|
+
// there's no unarchive. `confirm(question)` resolves to true to go ahead.
|
|
5
|
+
export async function deleteAgent(client, agentId, { confirm, log }) {
|
|
6
|
+
const agent = await client.beta.agents.retrieve(agentId);
|
|
7
|
+
if (agent.archived_at) throw new DeleteError(`${agentId} is already archived`);
|
|
8
|
+
const ok = await confirm(`Archive agent "${agent.name}" (${agentId})? It can't be undone.`);
|
|
9
|
+
if (!ok) return log("Nothing changed.");
|
|
10
|
+
await client.beta.agents.archive(agentId);
|
|
11
|
+
log(`Archived ${agentId}.`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Deletes a session and its event history for good.
|
|
15
|
+
export async function deleteSession(client, sessionId, { confirm, log }) {
|
|
16
|
+
const session = await client.beta.sessions.retrieve(sessionId);
|
|
17
|
+
if (session.status === "running") {
|
|
18
|
+
throw new DeleteError(`${sessionId} is running; wait for it to go idle, or interrupt it, then try again`);
|
|
19
|
+
}
|
|
20
|
+
const label = session.title ? `"${session.title}" (${sessionId})` : sessionId;
|
|
21
|
+
const ok = await confirm(`Delete session ${label} and its history? It can't be undone.`);
|
|
22
|
+
if (!ok) return log("Nothing changed.");
|
|
23
|
+
await client.beta.sessions.delete(sessionId);
|
|
24
|
+
log(`Deleted ${sessionId}.`);
|
|
25
|
+
}
|
package/src/deploy.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { check } from "./check.js";
|
|
3
|
+
import { AGENT_FILE, ENV_FILE, agentBody, envBody } from "./instance.js";
|
|
4
|
+
import { LOCK_FILE, hashBody, readLock, writeLock } from "./lock.js";
|
|
5
|
+
|
|
6
|
+
export class DeployError extends Error {}
|
|
7
|
+
|
|
8
|
+
// Creates or updates the environment and agent a folder defines, recording their IDs in claude-lock.json.
|
|
9
|
+
// Returns the plan: one { file, kind, action, id, version } entry per resource.
|
|
10
|
+
export async function deploy(dir, { client, dryRun = false, force = false, log = () => {} }) {
|
|
11
|
+
const { errors, instance } = check(dir);
|
|
12
|
+
if (errors.length > 0) throw new DeployError(`check failed:\n ${errors.join("\n ")}`);
|
|
13
|
+
|
|
14
|
+
const baseURL = client.baseURL;
|
|
15
|
+
const lock = readLock(dir) ?? { version: 1, origin: { base_url: baseURL }, resources: {} };
|
|
16
|
+
lock.resources ??= {};
|
|
17
|
+
if (lock.origin?.base_url && lock.origin.base_url !== baseURL) {
|
|
18
|
+
throw new DeployError(
|
|
19
|
+
`${LOCK_FILE} was written for ${lock.origin.base_url}, but this client uses ${baseURL}`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const steps = [
|
|
24
|
+
{ file: ENV_FILE, kind: "environment", body: envBody(instance.env) },
|
|
25
|
+
{ file: AGENT_FILE, kind: "agent", body: agentBody(instance.agent) },
|
|
26
|
+
];
|
|
27
|
+
for (const step of steps) {
|
|
28
|
+
step.key = `./${step.file}`;
|
|
29
|
+
step.hash = hashBody(step.body);
|
|
30
|
+
step.entry = lock.resources[step.key];
|
|
31
|
+
if (step.entry && step.entry.kind !== step.kind) {
|
|
32
|
+
throw new DeployError(`${LOCK_FILE}: entry for ${step.key} is a ${step.entry.kind}, expected ${step.kind}`);
|
|
33
|
+
}
|
|
34
|
+
step.action = !step.entry ? "create" : step.entry.hash === step.hash ? "unchanged" : "update";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const step of steps) {
|
|
38
|
+
if (dryRun || step.action === "unchanged") {
|
|
39
|
+
log(`${step.action.padEnd(9)} ${step.kind} ${step.entry?.id ?? ""}`.trimEnd());
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const result = step.kind === "environment"
|
|
43
|
+
? await deployEnvironment(client, step)
|
|
44
|
+
: await deployAgent(client, step, force);
|
|
45
|
+
|
|
46
|
+
lock.resources[step.key] = step.kind === "agent"
|
|
47
|
+
? { kind: "agent", id: result.id, version: String(result.version), hash: step.hash }
|
|
48
|
+
: { kind: "environment", id: result.id, hash: step.hash };
|
|
49
|
+
// Record each resource as soon as it exists, so a failure later doesn't orphan it.
|
|
50
|
+
writeLock(dir, lock);
|
|
51
|
+
|
|
52
|
+
const version = step.kind === "agent" ? ` (version ${result.version})` : "";
|
|
53
|
+
log(`${(step.action === "create" ? "created" : "updated").padEnd(9)} ${step.kind} ${result.id}${version}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return steps.map(({ file, kind, action, key }) => ({
|
|
57
|
+
file, kind, action,
|
|
58
|
+
id: lock.resources[key]?.id,
|
|
59
|
+
version: lock.resources[key]?.version,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function deployEnvironment(client, step) {
|
|
64
|
+
if (step.action === "create") return call(step, () => client.beta.environments.create(step.body));
|
|
65
|
+
|
|
66
|
+
const { id } = step.entry;
|
|
67
|
+
const remote = await retrieve(step, () => client.beta.environments.retrieve(id));
|
|
68
|
+
const body = { ...step.body };
|
|
69
|
+
// Updates keep any field you omit, so clear what was removed from env.md.
|
|
70
|
+
if (!("description" in body)) body.description = null;
|
|
71
|
+
body.metadata = metadataWithDeletions(body.metadata, remote.metadata);
|
|
72
|
+
return call(step, () => client.beta.environments.update(id, body));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function deployAgent(client, step, force) {
|
|
76
|
+
if (step.action === "create") return call(step, () => client.beta.agents.create(step.body));
|
|
77
|
+
|
|
78
|
+
const { id, version } = step.entry;
|
|
79
|
+
const remote = await retrieve(step, () => client.beta.agents.retrieve(id));
|
|
80
|
+
const body = { ...step.body };
|
|
81
|
+
// Updates keep any field you omit, so clear what was removed from agent.md.
|
|
82
|
+
if (!("system" in body)) body.system = null;
|
|
83
|
+
for (const field of ["description", "multiagent"]) if (!(field in body)) body[field] = null;
|
|
84
|
+
for (const field of ["tools", "mcp_servers", "skills"]) if (!(field in body)) body[field] = [];
|
|
85
|
+
body.metadata = metadataWithDeletions(body.metadata, remote.metadata);
|
|
86
|
+
// Sending the locked version makes the API refuse the update if someone changed the agent since.
|
|
87
|
+
if (!force && version !== undefined) body.version = Number(version);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
return await client.beta.agents.update(id, body);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err instanceof Anthropic.APIError && err.status === 409) {
|
|
93
|
+
throw new DeployError(
|
|
94
|
+
`agent ${id} was changed outside this folder (${LOCK_FILE} has version ${version}, ` +
|
|
95
|
+
`the agent is at version ${remote.version}). Re-run with --force to overwrite it.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
throw apiError(step, err);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Metadata updates merge, so keys removed from the file are sent as null to delete them.
|
|
103
|
+
function metadataWithDeletions(local = {}, remote = {}) {
|
|
104
|
+
const merged = { ...local };
|
|
105
|
+
for (const key of Object.keys(remote ?? {})) if (!(key in merged)) merged[key] = null;
|
|
106
|
+
return merged;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function retrieve(step, fn) {
|
|
110
|
+
let remote;
|
|
111
|
+
try {
|
|
112
|
+
remote = await fn();
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err instanceof Anthropic.APIError && err.status === 404) {
|
|
115
|
+
throw new DeployError(
|
|
116
|
+
`${step.kind} ${step.entry.id} in ${LOCK_FILE} no longer exists. ` +
|
|
117
|
+
`Remove the "${step.key}" entry from ${LOCK_FILE} to create a new one.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
throw apiError(step, err);
|
|
121
|
+
}
|
|
122
|
+
if (remote.archived_at) {
|
|
123
|
+
throw new DeployError(
|
|
124
|
+
`${step.kind} ${step.entry.id} is archived and can't be updated. ` +
|
|
125
|
+
`Remove the "${step.key}" entry from ${LOCK_FILE} to create a new one.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return remote;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function call(step, fn) {
|
|
132
|
+
try {
|
|
133
|
+
return await fn();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
throw apiError(step, err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function apiError(step, err) {
|
|
140
|
+
if (!(err instanceof Anthropic.APIError)) return err;
|
|
141
|
+
const message = err.error?.error?.message ?? err.message;
|
|
142
|
+
return new DeployError(`${step.file}: the API rejected the ${step.kind} (${err.status ?? "network error"}): ${message}`);
|
|
143
|
+
}
|
package/src/init.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import { createInterface, emitKeypressEvents } from "node:readline";
|
|
4
|
+
import { stringify } from "yaml";
|
|
5
|
+
import { AGENT_FILE, ENV_FILE } from "./instance.js";
|
|
6
|
+
|
|
7
|
+
export class InitError extends Error {}
|
|
8
|
+
|
|
9
|
+
export const MODELS = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5"];
|
|
10
|
+
export const APPROVALS = ["auto", "always_allow", "always_ask"];
|
|
11
|
+
export const NETWORKING = ["limited", "unrestricted"];
|
|
12
|
+
|
|
13
|
+
// The answers used when a question is skipped (--yes, or input ends early).
|
|
14
|
+
export function defaults(dir) {
|
|
15
|
+
const slug = basename(resolve(dir)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agent";
|
|
16
|
+
return {
|
|
17
|
+
name: slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" "),
|
|
18
|
+
model: MODELS[0],
|
|
19
|
+
system: "You are a helpful assistant.",
|
|
20
|
+
tools: true,
|
|
21
|
+
approval: "auto",
|
|
22
|
+
environment: slug,
|
|
23
|
+
networking: "limited",
|
|
24
|
+
packageManagers: true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Returns { "agent.md": text, "env.md": text } for a set of answers.
|
|
29
|
+
export function scaffold(answers) {
|
|
30
|
+
const agent = { name: answers.name, model: answers.model };
|
|
31
|
+
if (answers.tools) {
|
|
32
|
+
const toolset = { type: "agent_toolset_20260401" };
|
|
33
|
+
// always_allow is the toolset's default, so it needs no config.
|
|
34
|
+
if (answers.approval !== "always_allow") toolset.default_config = { permission_policy: { type: answers.approval } };
|
|
35
|
+
agent.tools = [toolset];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const networking = { type: answers.networking };
|
|
39
|
+
if (answers.networking === "limited") networking.allow_package_managers = answers.packageManagers;
|
|
40
|
+
const env = { name: answers.environment, config: { type: "cloud", networking } };
|
|
41
|
+
|
|
42
|
+
const access = answers.networking === "unrestricted"
|
|
43
|
+
? "unrestricted network access"
|
|
44
|
+
: answers.packageManagers ? "limited networking and package manager access" : "limited networking";
|
|
45
|
+
return {
|
|
46
|
+
[AGENT_FILE]: `---\n${stringify(agent)}---\n\n${answers.system.trim()}\n`,
|
|
47
|
+
[ENV_FILE]: `---\n${stringify(env)}---\nCloud container with ${access}.\n`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Asks the setup questions, then writes agent.md and env.md into a new folder `dir`.
|
|
52
|
+
export async function init(dir, { yes = false, input = process.stdin, output = process.stdout } = {}) {
|
|
53
|
+
if (existsSync(dir) && readdirSync(dir).length > 0) {
|
|
54
|
+
throw new InitError(`${dir} already exists and isn't empty`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const answers = defaults(dir);
|
|
58
|
+
if (!yes) await ask(answers, input, output);
|
|
59
|
+
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
const files = scaffold(answers);
|
|
62
|
+
for (const [file, text] of Object.entries(files)) writeFileSync(resolve(dir, file), text);
|
|
63
|
+
|
|
64
|
+
output.write(`\nCreated ${dir}/${AGENT_FILE} and ${dir}/${ENV_FILE}.\n\nNext:\n`);
|
|
65
|
+
output.write(` agentschema deploy ${dir}\n agentschema run ${dir}\n`);
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fills in `answers` from the user's replies; an empty reply keeps the default shown.
|
|
70
|
+
async function ask(answers, input, output) {
|
|
71
|
+
const io = input.isTTY ? terminalPrompts(input, output) : linePrompts(input, output);
|
|
72
|
+
const confirm = async (question, fallback) => {
|
|
73
|
+
while (true) {
|
|
74
|
+
const reply = (await io.text(`${question} [y/n]`, fallback ? "y" : "n")).toLowerCase();
|
|
75
|
+
if (/^(y|yes)$/.test(reply)) return true;
|
|
76
|
+
if (/^(n|no)$/.test(reply)) return false;
|
|
77
|
+
output.write("Answer y or n\n");
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
answers.name = await io.text("Agent name?", answers.name);
|
|
83
|
+
answers.model = await io.select("Model?", MODELS, answers.model);
|
|
84
|
+
answers.system = await io.text("System prompt?", answers.system);
|
|
85
|
+
answers.tools = await confirm("Give it the built-in tools (bash, files, web)?", answers.tools);
|
|
86
|
+
if (answers.tools) answers.approval = await io.select("Tool approvals?", APPROVALS, answers.approval);
|
|
87
|
+
answers.environment = await io.text("Environment name?", answers.environment);
|
|
88
|
+
answers.networking = await io.select("Networking?", NETWORKING, answers.networking);
|
|
89
|
+
if (answers.networking === "limited") {
|
|
90
|
+
answers.packageManagers = await confirm("Allow package managers (npm, pip, …)?", answers.packageManagers);
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
io.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Prompts for piped input: one answer per line, and choices picked by name or number.
|
|
98
|
+
function linePrompts(input, output) {
|
|
99
|
+
const rl = createInterface({ input, output, terminal: false });
|
|
100
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
101
|
+
let ended = false;
|
|
102
|
+
|
|
103
|
+
const text = async (question, fallback) => {
|
|
104
|
+
if (ended) return fallback;
|
|
105
|
+
output.write(`${question} (${fallback}) `);
|
|
106
|
+
const { value, done } = await lines.next();
|
|
107
|
+
if (done) {
|
|
108
|
+
ended = true;
|
|
109
|
+
output.write("\n");
|
|
110
|
+
return fallback;
|
|
111
|
+
}
|
|
112
|
+
return value.trim() || fallback;
|
|
113
|
+
};
|
|
114
|
+
const select = async (question, choices, fallback) => {
|
|
115
|
+
const listed = choices.map((c, i) => `${i + 1}) ${c}`).join(" ");
|
|
116
|
+
while (true) {
|
|
117
|
+
const reply = await text(`${question} ${listed}`, String(choices.indexOf(fallback) + 1));
|
|
118
|
+
const picked = choices[Number(reply) - 1] ?? choices.find((c) => c === reply);
|
|
119
|
+
if (picked) return picked;
|
|
120
|
+
output.write(`Pick one of: ${choices.join(", ")}\n`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
return { text, select, close: () => rl.close() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ESC = "\x1b[";
|
|
127
|
+
const cyan = (s) => `${ESC}36m${s}${ESC}0m`;
|
|
128
|
+
const dim = (s) => `${ESC}2m${s}${ESC}0m`;
|
|
129
|
+
|
|
130
|
+
// Prompts for a terminal: choices are picked with the arrow keys and Enter.
|
|
131
|
+
function terminalPrompts(input, output) {
|
|
132
|
+
const cancelled = () => new InitError("cancelled");
|
|
133
|
+
|
|
134
|
+
// A fresh readline per question, so it doesn't fight the raw-mode select for the arrow keys.
|
|
135
|
+
const text = (question, fallback) =>
|
|
136
|
+
new Promise((resolve, reject) => {
|
|
137
|
+
const rl = createInterface({ input, output, terminal: true });
|
|
138
|
+
rl.on("SIGINT", () => {
|
|
139
|
+
rl.close();
|
|
140
|
+
output.write("\n");
|
|
141
|
+
reject(cancelled());
|
|
142
|
+
});
|
|
143
|
+
rl.question(`${question} ${dim(`(${fallback})`)} `, (reply) => {
|
|
144
|
+
rl.close();
|
|
145
|
+
resolve(reply.trim() || fallback);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const select = (question, choices, fallback) =>
|
|
150
|
+
new Promise((resolve, reject) => {
|
|
151
|
+
let index = Math.max(0, choices.indexOf(fallback));
|
|
152
|
+
const render = (first) => {
|
|
153
|
+
if (!first) output.write(`${ESC}${choices.length}A`);
|
|
154
|
+
for (const [i, choice] of choices.entries()) {
|
|
155
|
+
output.write(`${ESC}2K${i === index ? cyan(`❯ ${choice}`) : ` ${choice}`}\n`);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const finish = (err) => {
|
|
159
|
+
input.off("keypress", onKey);
|
|
160
|
+
input.setRawMode(false);
|
|
161
|
+
input.pause();
|
|
162
|
+
// Collapse the list to one line with the answer.
|
|
163
|
+
output.write(`${ESC}${choices.length + 1}A${ESC}J${ESC}?25h`);
|
|
164
|
+
output.write(`${question} ${err ? dim("cancelled") : cyan(choices[index])}\n`);
|
|
165
|
+
err ? reject(err) : resolve(choices[index]);
|
|
166
|
+
};
|
|
167
|
+
const onKey = (str, key = {}) => {
|
|
168
|
+
if (key.ctrl && key.name === "c") return finish(cancelled());
|
|
169
|
+
if (key.name === "return" || key.name === "enter") return finish();
|
|
170
|
+
if (key.name === "up" || key.name === "k") index = (index - 1 + choices.length) % choices.length;
|
|
171
|
+
else if (key.name === "down" || key.name === "j" || key.name === "tab") index = (index + 1) % choices.length;
|
|
172
|
+
else if (/^[1-9]$/.test(str ?? "") && Number(str) <= choices.length) index = Number(str) - 1;
|
|
173
|
+
else return;
|
|
174
|
+
render(false);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
emitKeypressEvents(input);
|
|
178
|
+
input.setRawMode(true);
|
|
179
|
+
input.resume();
|
|
180
|
+
output.write(`${question} ${dim("(↑/↓, Enter)")}\n${ESC}?25l`);
|
|
181
|
+
render(true);
|
|
182
|
+
input.on("keypress", onKey);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return { text, select, close() {} };
|
|
186
|
+
}
|
package/src/instance.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
4
|
+
|
|
5
|
+
export const AGENT_FILE = "agent.md";
|
|
6
|
+
export const ENV_FILE = "env.md";
|
|
7
|
+
|
|
8
|
+
export class FileError extends Error {}
|
|
9
|
+
|
|
10
|
+
// Splits a Markdown file into YAML frontmatter (between `---` fences) and body.
|
|
11
|
+
export function parseMarkdown(text, file) {
|
|
12
|
+
const normalized = text.replace(/^/, "").replace(/\r\n/g, "\n");
|
|
13
|
+
if (!normalized.startsWith("---\n")) {
|
|
14
|
+
throw new FileError(`${file}: must start with YAML frontmatter between --- lines`);
|
|
15
|
+
}
|
|
16
|
+
const closing = /^---[ \t]*$/m.exec(normalized.slice(4));
|
|
17
|
+
if (!closing) throw new FileError(`${file}: frontmatter is not closed with a --- line`);
|
|
18
|
+
const end = 4 + closing.index;
|
|
19
|
+
const afterFence = normalized.slice(end + closing[0].length);
|
|
20
|
+
|
|
21
|
+
const doc = parseDocument(normalized.slice(4, end));
|
|
22
|
+
if (doc.errors.length > 0) {
|
|
23
|
+
// The YAML library counts lines from the start of the frontmatter; +1 for the opening fence.
|
|
24
|
+
const err = doc.errors[0];
|
|
25
|
+
const line = err.linePos?.[0]?.line;
|
|
26
|
+
const where = line ? `:${line + 1}` : "";
|
|
27
|
+
throw new FileError(`${file}${where}: invalid YAML: ${err.message.split("\n")[0]}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const data = doc.toJS() ?? {};
|
|
31
|
+
if (typeof data !== "object" || Array.isArray(data)) {
|
|
32
|
+
throw new FileError(`${file}: frontmatter must be a YAML mapping (key: value pairs)`);
|
|
33
|
+
}
|
|
34
|
+
return { data, body: afterFence.trim(), raw: text };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Reads agent.md and env.md from an instance folder.
|
|
38
|
+
// Returns { agent, env, errors }; a file that can't be read is null and has an entry in errors.
|
|
39
|
+
export function readInstance(dir) {
|
|
40
|
+
let stat;
|
|
41
|
+
try {
|
|
42
|
+
stat = statSync(dir);
|
|
43
|
+
} catch {
|
|
44
|
+
return { agent: null, env: null, errors: [`${dir}: no such directory`] };
|
|
45
|
+
}
|
|
46
|
+
if (!stat.isDirectory()) return { agent: null, env: null, errors: [`${dir}: not a directory`] };
|
|
47
|
+
|
|
48
|
+
// Compare exact names: macOS file systems would otherwise open AGENT.md as agent.md.
|
|
49
|
+
const names = readdirSync(dir);
|
|
50
|
+
const errors = [];
|
|
51
|
+
const read = (name) => {
|
|
52
|
+
try {
|
|
53
|
+
if (!names.includes(name)) {
|
|
54
|
+
const wrongCase = names.find((n) => n.toLowerCase() === name);
|
|
55
|
+
const hint = wrongCase ? ` (found ${wrongCase}; the name must be lowercase)` : "";
|
|
56
|
+
throw new FileError(`${name}: missing${hint}`);
|
|
57
|
+
}
|
|
58
|
+
return parseMarkdown(readFileSync(join(dir, name), "utf8"), name);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (!(err instanceof FileError)) throw err;
|
|
61
|
+
errors.push(err.message);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return { agent: read(AGENT_FILE), env: read(ENV_FILE), errors };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The bodies sent to POST /v1/agents and POST /v1/environments.
|
|
70
|
+
export function agentBody(agent) {
|
|
71
|
+
const body = { ...agent.data };
|
|
72
|
+
if (agent.body) body.system = agent.body;
|
|
73
|
+
return body;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function envBody(env) {
|
|
77
|
+
return { ...env.data };
|
|
78
|
+
}
|
package/src/list.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Lists the agents and sessions in the workspace the credentials belong to.
|
|
2
|
+
|
|
3
|
+
export async function listAgents(client) {
|
|
4
|
+
const rows = [];
|
|
5
|
+
for await (const agent of client.beta.agents.list()) {
|
|
6
|
+
rows.push({
|
|
7
|
+
name: agent.name,
|
|
8
|
+
id: agent.id,
|
|
9
|
+
version: String(agent.version),
|
|
10
|
+
model: agent.model?.id ?? "",
|
|
11
|
+
updated: formatDate(agent.updated_at),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return table(rows, { name: "NAME", id: "ID", version: "VERSION", model: "MODEL", updated: "UPDATED" }, "No agents.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function listSessions(client) {
|
|
18
|
+
const rows = [];
|
|
19
|
+
// Newest first, the API's default order.
|
|
20
|
+
for await (const session of client.beta.sessions.list()) {
|
|
21
|
+
rows.push({
|
|
22
|
+
id: session.id,
|
|
23
|
+
status: session.status,
|
|
24
|
+
agent: session.agent ? `${session.agent.name} v${session.agent.version}` : "",
|
|
25
|
+
title: truncate(session.title ?? "", 40),
|
|
26
|
+
created: formatDate(session.created_at),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return table(rows, { id: "ID", status: "STATUS", agent: "AGENT", title: "TITLE", created: "CREATED" }, "No sessions.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Lines of a plain-text table, with columns padded to their widest value.
|
|
33
|
+
function table(rows, headers, empty) {
|
|
34
|
+
if (rows.length === 0) return [empty];
|
|
35
|
+
const keys = Object.keys(headers);
|
|
36
|
+
const widths = keys.map((k) => Math.max(headers[k].length, ...rows.map((r) => r[k].length)));
|
|
37
|
+
const line = (r) => keys.map((k, i) => (i === keys.length - 1 ? r[k] : r[k].padEnd(widths[i]))).join(" ").trimEnd();
|
|
38
|
+
return [line(headers), ...rows.map(line)];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// "2026-11-03 14:02" in local time.
|
|
42
|
+
function formatDate(iso) {
|
|
43
|
+
if (!iso) return "";
|
|
44
|
+
const d = new Date(iso);
|
|
45
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
46
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
package/src/lock.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const LOCK_FILE = "claude-lock.json";
|
|
6
|
+
|
|
7
|
+
// Same layout as the lock file `ant apply` writes: one entry per file, keyed by "./<file>".
|
|
8
|
+
export function readLock(dir) {
|
|
9
|
+
const path = join(dir, LOCK_FILE);
|
|
10
|
+
if (!existsSync(path)) return null;
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
13
|
+
} catch (err) {
|
|
14
|
+
throw new Error(`${LOCK_FILE}: not valid JSON (${err.message})`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeLock(dir, lock) {
|
|
19
|
+
writeFileSync(join(dir, LOCK_FILE), JSON.stringify(lock, null, 2) + "\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Hash of a request body with keys sorted, so key order in the YAML doesn't count as a change.
|
|
23
|
+
export function hashBody(body) {
|
|
24
|
+
return createHash("md5").update(canonicalJSON(body)).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function canonicalJSON(value) {
|
|
28
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(",")}]`;
|
|
29
|
+
if (value && typeof value === "object") {
|
|
30
|
+
const keys = Object.keys(value).sort();
|
|
31
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJSON(value[k])}`).join(",")}}`;
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify(value);
|
|
34
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { check } from "./check.js";
|
|
2
|
+
import { AGENT_FILE, ENV_FILE, agentBody, envBody } from "./instance.js";
|
|
3
|
+
import { LOCK_FILE, hashBody, readLock } from "./lock.js";
|
|
4
|
+
|
|
5
|
+
export class SessionError extends Error {}
|
|
6
|
+
|
|
7
|
+
// Starts a session on the agent and environment recorded in claude-lock.json, or resumes `sessionId`.
|
|
8
|
+
// Returns { session, notes }: notes are warnings worth showing, such as files changed since the last deploy.
|
|
9
|
+
export async function openSession(dir, { client, sessionId }) {
|
|
10
|
+
if (sessionId) {
|
|
11
|
+
const session = await client.beta.sessions.retrieve(sessionId);
|
|
12
|
+
if (session.status === "terminated") throw new SessionError(`session ${sessionId} has terminated`);
|
|
13
|
+
return { session, notes: [] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const lock = readLock(dir);
|
|
17
|
+
const agent = lock?.resources?.[`./${AGENT_FILE}`];
|
|
18
|
+
const env = lock?.resources?.[`./${ENV_FILE}`];
|
|
19
|
+
if (!agent || !env) {
|
|
20
|
+
throw new SessionError(`${dir} hasn't been deployed (no agent or environment in ${LOCK_FILE}); run agentschema deploy first`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const notes = [];
|
|
24
|
+
const { instance } = check(dir);
|
|
25
|
+
if (instance) {
|
|
26
|
+
if (hashBody(agentBody(instance.agent)) !== agent.hash) notes.push(`${AGENT_FILE} has changed since the last deploy`);
|
|
27
|
+
if (hashBody(envBody(instance.env)) !== env.hash) notes.push(`${ENV_FILE} has changed since the last deploy`);
|
|
28
|
+
if (notes.length > 0) notes.push(`this session uses the deployed version; run agentschema deploy to pick up the changes`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Pin the deployed version, so the session runs exactly what deploy last sent.
|
|
32
|
+
const params = {
|
|
33
|
+
agent: { type: "agent", id: agent.id, version: Number(agent.version) },
|
|
34
|
+
environment_id: env.id,
|
|
35
|
+
};
|
|
36
|
+
const name = instance?.agent.data.name;
|
|
37
|
+
params.title = name ? `${name} (agentschema run)` : "agentschema run";
|
|
38
|
+
return { session: await client.beta.sessions.create(params), notes };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Drives a session one turn at a time. `ui` answers what only a person can:
|
|
42
|
+
// ui.event(event) show an event
|
|
43
|
+
// ui.confirm(event) -> { result: "allow" | "deny", deny_message? } for a paused tool call
|
|
44
|
+
// ui.customToolResult(event) -> string, the result of a custom tool call
|
|
45
|
+
export class Conversation {
|
|
46
|
+
constructor(client, sessionId, ui) {
|
|
47
|
+
this.client = client;
|
|
48
|
+
this.sessionId = sessionId;
|
|
49
|
+
this.ui = ui;
|
|
50
|
+
this.seen = new Set();
|
|
51
|
+
this.stream = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Sends a message and handles events until the agent finishes its turn.
|
|
55
|
+
// Returns the idle stop reason, or "terminated".
|
|
56
|
+
async send(text) {
|
|
57
|
+
return this.#turn([{ type: "user.message", content: [{ type: "text", text }] }]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Asks the agent to stop at the next safe point; the running turn then ends normally.
|
|
61
|
+
async interrupt() {
|
|
62
|
+
await this.client.beta.sessions.events.send(this.sessionId, { events: [{ type: "user.interrupt" }] });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async #turn(events) {
|
|
66
|
+
// Stream first: events emitted before the stream opens would never reach us.
|
|
67
|
+
this.stream = await this.client.beta.sessions.events.stream(this.sessionId);
|
|
68
|
+
await this.client.beta.sessions.events.send(this.sessionId, { events });
|
|
69
|
+
|
|
70
|
+
for (let attempt = 0; ; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
for await (const event of this.stream) {
|
|
73
|
+
const done = await this.#handle(event);
|
|
74
|
+
if (done) return done;
|
|
75
|
+
}
|
|
76
|
+
throw new Error("event stream closed");
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (this.closed || attempt >= 3) throw err;
|
|
79
|
+
// The stream has no replay: reopen it, then catch up from the event history.
|
|
80
|
+
this.stream = await this.client.beta.sessions.events.stream(this.sessionId);
|
|
81
|
+
for await (const event of this.client.beta.sessions.events.list(this.sessionId)) {
|
|
82
|
+
const done = await this.#handle(event);
|
|
83
|
+
if (done) return done;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Returns a value when the turn is over.
|
|
90
|
+
async #handle(event) {
|
|
91
|
+
// Events are deduped by ID: the history and the live stream overlap after a reconnect,
|
|
92
|
+
// and a queued user event is echoed twice.
|
|
93
|
+
if (event.id) {
|
|
94
|
+
if (this.seen.has(event.id)) return null;
|
|
95
|
+
this.seen.add(event.id);
|
|
96
|
+
}
|
|
97
|
+
this.ui.event(event);
|
|
98
|
+
|
|
99
|
+
const send = (e) => this.client.beta.sessions.events.send(this.sessionId, { events: [e] });
|
|
100
|
+
switch (event.type) {
|
|
101
|
+
case "agent.tool_use":
|
|
102
|
+
case "agent.mcp_tool_use":
|
|
103
|
+
if (event.evaluated_permission === "ask") {
|
|
104
|
+
const { result, deny_message } = await this.ui.confirm(event);
|
|
105
|
+
const reply = { type: "user.tool_confirmation", tool_use_id: event.id, result };
|
|
106
|
+
if (result === "deny" && deny_message) reply.deny_message = deny_message;
|
|
107
|
+
await send(reply);
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
case "agent.custom_tool_use": {
|
|
111
|
+
const text = await this.ui.customToolResult(event);
|
|
112
|
+
await send({ type: "user.custom_tool_result", custom_tool_use_id: event.id, content: [{ type: "text", text }] });
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
case "session.status_idle":
|
|
116
|
+
// requires_action means the session is waiting on one of the replies above.
|
|
117
|
+
return event.stop_reason?.type === "requires_action" ? null : (event.stop_reason?.type ?? "end_turn");
|
|
118
|
+
case "session.status_terminated":
|
|
119
|
+
return "terminated";
|
|
120
|
+
default:
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
close() {
|
|
126
|
+
this.closed = true;
|
|
127
|
+
this.stream?.controller?.abort();
|
|
128
|
+
}
|
|
129
|
+
}
|