Skip to content

Commit b62dda9

Browse files
byapparovclaude
andcommitted
fix(headless): send the same system prompt from the full and lite builds (#152)
The lite entry point (src/headless.ts) set AICTRL_HEADLESS=true, and the session prompt read it to drop the environment block (working directory, git status, platform) and the AICTRL_CONFIG_DIR and global AGENTS.md files. The build define of the same name was a bare identifier nothing read, so the published full binary never took that path: the same `aictrl run` sent different system prompts depending on which binary ran it. Remove the env branches, the env write and the no-op define. Both builds now send the full binary's prompt, which is what consumers already get. HeadlessRun stays the only headless-run signal (skill scope). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015nwvfUHdM3bgMgD9Zpz7Mj
1 parent 98f2754 commit b62dda9

8 files changed

Lines changed: 93 additions & 39 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,17 @@
1212
- **Retry waits are bounded for every status** — No single wait between retries exceeds 5 minutes. A retryable error of any status whose `Retry-After` / `retry-after-ms` asks for longer is no longer retried: a 429 still fails as `usage_limit` (#174), and any other status (for example a 503 or 529 asking for `retry-after: 18000`, which used to sleep about 5 hours per attempt) fails at once with the provider error, `session_error.reason` from its status (`provider` for a 5xx) and exit 1, with no `retry` event. Exponential backoff without a retry hint now stops at 30 s even when the response has headers, as every AI SDK HTTP error does, so a hintless 429 or 5xx retries for about 3.5 minutes in all instead of about 34. A zero or negative `Retry-After` now counts as no hint and backs off, instead of retrying with no wait. With 10 retries the waits total at most 50 minutes. (closes #175)
1313
- **`session_start.permissions` is complete** — it now lists the full ruleset tool calls are evaluated against (agent defaults, config `permission` rules, agent rules and session rules) in evaluation order; it was empty for the default agent and omitted session rules (#212).
1414
- **Subagent token usage is reported** — `aictrl run --format json` emits a new `subagent_message_complete` event for each finished assistant turn of a `task` subagent, with the same usage fields as `message_complete` plus `subagentSessionID` and `parentSessionID`. Summing `message_complete` and `subagent_message_complete` gives the run's total; before, a run that delegated reported only the parent's turns. `message_complete` is unchanged and still covers the primary session only (#213).
15+
- **Lite build sends the same system prompt as the full build** — `aictrl run` from the lite binary (`script/build.ts --lite`, entry `src/headless.ts`) now sends the environment block (working directory, git status, platform, date) and loads `AGENTS.md` from `AICTRL_CONFIG_DIR` and the global config directory, as the published full binary always did. The lite-only short prompt is gone. The no-op `AICTRL_HEADLESS` build define and the `AICTRL_HEADLESS` environment variable are removed; setting the variable has no effect. The full binary's prompt is unchanged. (closes #152)
1516

1617
### Internal
1718

1819
- **A session runs one prompt at a time** — The prompt loop no longer queues a second prompt for a session that is already running one. The second prompt now fails with `Session <id> is busy: it already has a running prompt` before it writes a user message. A headless run has one user turn, so the only reachable case is two parallel `task` calls that resume the same `task_id`: the second call now returns that error to the model instead of waiting. The unused `!` shell command path (`SessionPrompt.shell`) and `Session.initialize` are removed. (Part of #223)
20+
- **One Bun version for CI, releases and local builds** — The root `package.json` pins `packageManager` to `[email protected]`, and the CI and publish workflows read it with `bun-version-file: package.json` instead of installing `latest`. 0.5.1 shipped Bun 1.4.3 while CI and local builds could use other versions; a local 1.3.10 build of the same commit was about 85 ms slower to first response and used about 85 MB more RSS. The pre-push hook and `@aictrl/script` (used by the build) check the local Bun against `packageManager`, so they now require Bun 1.4.3 or a later 1.x. The `bun-node` container image no longer has its own default `BUN_VERSION` and takes the `packageManager` version from its build script.
1921

2022
### Security
2123

2224
- **drizzle-orm 1.0.0-beta.20** — `drizzle-orm` and `drizzle-kit` move from `1.0.0-beta.12-a5629fb` to `1.0.0-beta.20`, the first release patched for GHSA-gpj5-g38j-94v9 (SQL injection through improperly escaped SQL identifiers; the CLI never passed untrusted identifiers to drizzle). Existing databases open as before: on first start drizzle adds `name` and `applied_at` columns to `__drizzle_migrations` and fills in the names of the migrations already applied, without running them again. The upgraded database still opens with 0.5.1. (closes #61)
2325

24-
### Internal
25-
26-
- **One Bun version for CI, releases and local builds** — The root `package.json` pins `packageManager` to `[email protected]`, and the CI and publish workflows read it with `bun-version-file: package.json` instead of installing `latest`. 0.5.1 shipped Bun 1.4.3 while CI and local builds could use other versions; a local 1.3.10 build of the same commit was about 85 ms slower to first response and used about 85 MB more RSS. The pre-push hook and `@aictrl/script` (used by the build) check the local Bun against `packageManager`, so they now require Bun 1.4.3 or a later 1.x. The `bun-node` container image no longer has its own default `BUN_VERSION` and takes the `packageManager` version from its build script.
27-
2826
## 0.5.1 (2026-10-18)
2927

3028
### Fixes

‎README.md‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,6 @@ config directories (`~/.config/aictrl`, `~/.aictrl`) and config
8080
or set config `skills.allow` to list and load only the named skills.
8181
See the [full contract](packages/cli/README.md#skill-scope).
8282

83-
### CI/CD Integration
84-
85-
Set `AICTRL_HEADLESS=true` in your environment to force headless behavior even in pseudo-TTYs.
86-
8783
### Restricting Dangerous Commands
8884

8985
Tool descriptions describe tools; they are not a security boundary. Block

‎packages/cli/script/build.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ for (const item of targets) {
138138
AICTRL_VERSION: `'${Script.version}'`,
139139
AICTRL_MIGRATIONS: JSON.stringify(migrations),
140140
AICTRL_CHANNEL: `'${Script.channel}'`,
141-
AICTRL_HEADLESS: liteFlag ? "'true'" : "'false'",
142141
},
143142
})
144143

‎packages/cli/src/headless.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ let cli = yargs(hideBin(process.argv))
6767

6868
process.env.AGENT = "1"
6969
process.env.AICTRL_PID = String(process.pid)
70-
process.env.AICTRL_HEADLESS = "true"
7170

7271
Log.Default.info("aictrl-headless", {
7372
version: Installation.VERSION,

‎packages/cli/src/session/instruction.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@ const FILES = ["AGENTS.md"]
1515

1616
function globalFiles() {
1717
const files = []
18-
if (process.env.AICTRL_HEADLESS === "true") {
19-
files.push(path.join(Instance.directory, ".opencode", "AGENTS.md"))
20-
return files
21-
}
2218
if (Flag.AICTRL_CONFIG_DIR) {
2319
files.push(path.join(Flag.AICTRL_CONFIG_DIR, "AGENTS.md"))
2420
}

‎packages/cli/src/session/system.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,6 @@ export namespace SystemPrompt {
2727
}
2828

2929
export async function environment(model: Provider.Model) {
30-
if (process.env.AICTRL_HEADLESS === "true") {
31-
return [
32-
`You are powered by ${model.providerID}/${model.api.id}. Today's date is ${new Date().toDateString()}.`,
33-
]
34-
}
3530
const project = Instance.project
3631
return [
3732
[
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { expect, test } from "bun:test"
2+
import { $ } from "bun"
3+
import path from "path"
4+
import { tmpdir } from "../fixture/fixture"
5+
6+
/**
7+
* The full build compiles src/index.ts and the lite build compiles src/headless.ts.
8+
* The same `aictrl run` must send the same system prompt from either entry point (#152).
9+
*/
10+
const entries = {
11+
full: path.resolve(import.meta.dir, "../../src/index.ts"),
12+
lite: path.resolve(import.meta.dir, "../../src/headless.ts"),
13+
}
14+
15+
type Request = { messages: { role: string; content?: unknown }[] }
16+
17+
/** Runs `aictrl run` from `entry` and returns the system messages of the first model request. */
18+
async function firstSystemPrompt(entry: string, root: string) {
19+
const requests: Request[] = []
20+
const server = Bun.serve({
21+
hostname: "127.0.0.1",
22+
port: 0,
23+
async fetch(req) {
24+
requests.push((await req.json()) as Request)
25+
const chunks = [
26+
{ delta: { role: "assistant", content: "done" }, finish_reason: null },
27+
{ delta: {}, finish_reason: "stop" },
28+
].map(
29+
(choice) =>
30+
`data: ${JSON.stringify({ id: "fixture", object: "chat.completion.chunk", created: 1, model: "fixture", choices: [{ index: 0, ...choice }] })}\n\n`,
31+
)
32+
return new Response(chunks.join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } })
33+
},
34+
})
35+
const config = {
36+
provider: {
37+
fixture: {
38+
npm: "@ai-sdk/openai-compatible",
39+
options: { apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` },
40+
models: { fixture: { name: "fixture", tool_call: true, limit: { context: 100000, output: 1000 } } },
41+
},
42+
},
43+
agent: { title: { disable: true } },
44+
}
45+
const proc = Bun.spawn(
46+
["bun", "run", "--conditions=browser", entry, "run", "--format", "json", "--model", "fixture/fixture", "Say done."],
47+
{
48+
cwd: path.join(root, "project"),
49+
env: {
50+
...process.env,
51+
AICTRL_CONFIG_CONTENT: JSON.stringify(config),
52+
HOME: path.join(root, "home"),
53+
AICTRL_TEST_HOME: path.join(root, "home"),
54+
AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(root, "managed"),
55+
XDG_DATA_HOME: path.join(root, "data"),
56+
XDG_CONFIG_HOME: path.join(root, "config"),
57+
XDG_STATE_HOME: path.join(root, "state"),
58+
XDG_CACHE_HOME: path.join(root, "cache"),
59+
},
60+
stdout: "pipe",
61+
stderr: "pipe",
62+
},
63+
)
64+
const timeout = setTimeout(() => proc.kill("SIGKILL"), 15000)
65+
try {
66+
const [stderr, exit] = await Promise.all([new Response(proc.stderr).text(), proc.exited])
67+
expect(exit, stderr).toBe(0)
68+
return requests[0].messages.filter((message) => message.role === "system")
69+
} finally {
70+
clearTimeout(timeout)
71+
server.stop(true)
72+
}
73+
}
74+
75+
test("full and lite entry points send the same system prompt for `aictrl run` (#152)", async () => {
76+
await using tmp = await tmpdir()
77+
const project = path.join(tmp.path, "project")
78+
await Bun.write(path.join(tmp.path, "config", "aictrl", "AGENTS.md"), "# Global aictrl instructions")
79+
await Bun.write(path.join(project, ".keep"), "")
80+
await $`git init`.cwd(project).quiet()
81+
82+
const full = await firstSystemPrompt(entries.full, tmp.path)
83+
const lite = await firstSystemPrompt(entries.lite, tmp.path)
84+
85+
expect(lite).toEqual(full)
86+
const prompt = JSON.stringify(full)
87+
expect(prompt).toContain(`Working directory: ${project}`)
88+
expect(prompt).toContain("# Global aictrl instructions")
89+
}, 40000)

‎packages/cli/test/session/headless-prompt-budget.test.ts‎

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@ import { alibabaProvider } from "../fixture/providers"
2020
*/
2121
const HEADLESS_PREFIX_BUDGET = 29_500
2222

23-
// The lite build's prompt contract.
24-
const HEADLESS_ENV = { AICTRL_HEADLESS: "true" }
25-
2623
type Piece = { name: string; chars: number }
2724

2825
/** System prompt pieces, assembled the way SessionPrompt and LLM.stream assemble them. */
@@ -58,23 +55,8 @@ function breakdown(pieces: Piece[]) {
5855
}
5956

6057
describe("headless request prefix budget (#160)", () => {
61-
const previous: Record<string, string | undefined> = {}
62-
63-
beforeAll(() => {
64-
HeadlessRun.enable()
65-
for (const [key, value] of Object.entries(HEADLESS_ENV)) {
66-
previous[key] = process.env[key]
67-
process.env[key] = value
68-
}
69-
})
70-
71-
afterAll(() => {
72-
HeadlessRun.disable()
73-
for (const [key, value] of Object.entries(previous)) {
74-
if (value === undefined) delete process.env[key]
75-
else process.env[key] = value
76-
}
77-
})
58+
beforeAll(() => HeadlessRun.enable())
59+
afterAll(() => HeadlessRun.disable())
7860

7961
test("system prompt plus tool definitions stay within the budget", async () => {
8062
await using tmp = await tmpdir({

0 commit comments

Comments
 (0)