Compare commits
3
Commits
deeb138cd9
...
7d77408a41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d77408a41 | ||
|
|
44bb4bebf1 | ||
|
|
76043aeb38 |
No files matched your search
@@ -0,0 +1,51 @@
|
||||
# Auto-scaffold project workflow files on first use in an existing repo
|
||||
|
||||
## Problem
|
||||
`/init` already scaffolds TODO.md / ROADMAP.md / docs/ **manually** (plus writes AGENTS.md
|
||||
via the model). But when the user runs shiro against an *existing* project that has no
|
||||
tracking files, nothing is generated automatically — the workflow policy block stays out of
|
||||
the system prompt, no nudges ever fire, and the model has no task list context.
|
||||
|
||||
The user's ask: when the agent starts working in an existing repo that lacks
|
||||
TODO.md / ROADMAP.md / docs/, generate them (also auto-write AGENTS.md), so the workflow
|
||||
hooks in before the first turn.
|
||||
|
||||
## Scope
|
||||
- Auto-generate TODO.md, ROADMAP.md, docs/, and AGENTS.md in an existing repo that has none
|
||||
of them, before the first user turn.
|
||||
- Model-driven content (the `/init` prompt pattern) rather than empty templates, so the
|
||||
files reflect the actual project.
|
||||
- Opt-out: `workflow.autoScaffold: false` disables; `workflow.enabled: false` stays the master
|
||||
switch. Also bail if the repo already tracks anything (TODO/ROADMAP/docs or AGENTS.md) — a
|
||||
repo that self-tracks does not need re-scaffolding.
|
||||
- Generated files are surfaced as a `notice` event so the CLI/UI can show them.
|
||||
- No overwriting: never touch an existing file.
|
||||
|
||||
## Files touched
|
||||
- `src/scaffold.ts` — add `scaffoldMissingAuto(cwd, model)` that (a) checks git root,
|
||||
(b) if no TODO/ROADMAP/docs/AGENTS.md exist, generates them via one model call
|
||||
(reuse `INIT_PROMPT`-style tone, but cover all four files), writing directly.
|
||||
- `src/session.ts` — in `send()` (first turn only, `workflow.autoScaffold !== false` and
|
||||
`workflow.enabled !== false`), call `scaffoldMissingAuto` once; if anything was written,
|
||||
set a flag so `systemFor()`'s `workflowPolicy()` sees the files right away, bump
|
||||
`versions.workflow`, and yield a `notice`.
|
||||
- `src/config.ts` — parse `workflow.autoScaffold` from config.
|
||||
- `src/session.ts` `SessionOptions.workflow` — add `autoScaffold?: boolean` (default true).
|
||||
- `test/scaffold.test.ts` (new or extend) — auto-scaffold on a repo with nothing; bail when
|
||||
TODO.md exists; bail when AGENTS.md exists; opt-out flag.
|
||||
- `docs/workflow.md` — document auto-scaffold + the flag.
|
||||
|
||||
## Verification
|
||||
- `bun run typecheck` clean.
|
||||
- `bun test` full suite green.
|
||||
- New tests cover: auto-scaffold writes 4 files in a bare repo; existing TODO.md bails;
|
||||
existing AGENTS.md bails; `autoScaffold:false` skips; notice event emitted.
|
||||
- `bun run build` compiles.
|
||||
|
||||
## Risks / decisions
|
||||
- One model call for all four files keeps it cheap and atomic-ish; content is project-specific.
|
||||
- Runs once per session (flag), at first `send()` *before* the model's real turn, so the
|
||||
system prompt and the turn see it. If the model call fails, degrade to the existing
|
||||
`scaffoldWorkflowFiles` empty-template fallback — never fail the turn.
|
||||
- Cwd-relative writes: resolve against the **git root**, not `process.cwd()` (matches
|
||||
`workflowPolicy`). Nested-cwd runs still write at the repo root.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Background bash — run long-lived commands without blocking the turn
|
||||
|
||||
Status: spec (implemented)
|
||||
Date: 2026-09-11
|
||||
|
||||
## Problem
|
||||
|
||||
Running a dev server (or any long-lived command) via `bash` blocks the tool
|
||||
until the process exits or the 120 s default timeout fires. A dev server never
|
||||
exits, so the model burns the turn waiting, then gets a killed-by-timeout error
|
||||
in which the server may or may not still be running. Dev workflows inside the
|
||||
agent are effectively impossible.
|
||||
|
||||
## Design
|
||||
|
||||
Add an optional `background: true` mode to `bash`. Background commands:
|
||||
|
||||
- spawn detached (new process group / session) so the agent process can exit
|
||||
without taking them down, and so ctrl-c in the agent never kills them.
|
||||
- return immediately with a `handle` (small integer), a `started` marker, and
|
||||
the first few lines of output (when available).
|
||||
- keep streaming output into a per-handle ring buffer (capped) in memory;
|
||||
`bash_status` returns recent output and the current running/finished state.
|
||||
- are reaped on agent shutdown — the module saves a `~/.shiro-neko/backgrounds.json`
|
||||
journal and kills live children on exit (kill `Bun.spawn` process).
|
||||
- can be killed explicitly via `bash_stop` (the tool), or ctrl-c while focused,
|
||||
or `/bash` (the command).
|
||||
|
||||
### Why a handle + tools, not a long-lived "bash" result
|
||||
|
||||
Background processes are by definition not one-shot, so a single tool result
|
||||
cannot represent them. Separating into `bash` (start/one-shot) + `bash_status`
|
||||
(poll) + `bash_stop` (kill) keeps each tool's contract small and lets the agent
|
||||
poll while continuing to work. The model is instructed to poll and stop when
|
||||
done; otherwise the process lingers until shutdown reaps it.
|
||||
|
||||
### Why detached
|
||||
|
||||
`Bun.spawn(..., {detached: true})` (a.k.a. setsid) is required so that:
|
||||
- killing the agent does not SIGKILL the dev server (kill process-group on exit
|
||||
is deliberate, see below);
|
||||
- ctrl-c in the agent (which kills the agent's own process group) does not
|
||||
signal the dev server;
|
||||
- `interruptBash()` keeps working for foreground commands only.
|
||||
|
||||
On Windows, process groups work differently (`detached` behaves differently in
|
||||
Bun; the kill is best-effort). Document that background is primarily for
|
||||
Unix-like dev servers.
|
||||
|
||||
## Tool changes
|
||||
|
||||
### `bash` — add `background?: boolean` and `name?: string`
|
||||
|
||||
- `background` default false (foreground = existing behavior, backward compat).
|
||||
- `name` optional label used for /bash listing.
|
||||
- When background:
|
||||
- spawn `bash -lc '<cmd>'` with `detached: true` (or `cmd /c` + best-effort on
|
||||
win32), pipes captured for streaming, no tool timeout (the process decides
|
||||
its own lifetime).
|
||||
- register in the module-level `backgrounds` map keyed by an incrementing
|
||||
handle.
|
||||
- return `running <handle>: <cmd> (background pid N)` — the model learns the
|
||||
handle and can poll.
|
||||
- The `running` map (foreground, `interruptBash`) is untouched: foreground
|
||||
commands still behave exactly as today.
|
||||
|
||||
### `bash_status` — new `nav`/`core` read tool
|
||||
|
||||
- Input: `handle: number`.
|
||||
- Output: one of:
|
||||
- `running`: `status: running (pid N)\n<recent output, tail capped>`
|
||||
- `finished`: `status: finished, exit: <code>\n<tail of captured output>`
|
||||
- `not found`: `status: no such handle`
|
||||
- Implementation reuses `bashListener` streaming (sessions get live progress
|
||||
while a background command runs) and keeps a tail buffer per handle
|
||||
(`MAX_OUTPUT`-capped, so the model never burns context).
|
||||
|
||||
### `bash_stop` — new mutating tool
|
||||
|
||||
- Input: `handle: number`.
|
||||
- Returns which command was killed (`killed <handle>: <cmd>`), or
|
||||
`no such handle` when absent. Reuses `killTree` (process-group aware) on the
|
||||
background process, awaited so the process really is gone.
|
||||
|
||||
### Tool registrations
|
||||
|
||||
- `bash_status`: `withMeta({ set: 'core', mutating: false })`, read tool, no
|
||||
approval needed (like `read_file`).
|
||||
- `bash_stop`: `withMeta({ set: 'core', mutating: true })` — mutating requires
|
||||
a `DEFAULT_PERMISSIONS` entry + `subjectOf` case in `src/permission.ts`
|
||||
(falls back to `ask` on `*` otherwise, bypassing command gating).
|
||||
- `bash` remains `mutating: true`; `bash_stop` and `bash` share the bash
|
||||
permission subject (`bash` subjectOf: `bash_stop` command = `bash <cmd>`),
|
||||
so an approved `bash` rule can also cover `bash_stop` (subject-derived).
|
||||
- `MUTATING_TOOLS`/`TOOL_SETS` derive automatically via `_meta`.
|
||||
|
||||
### Default permissions
|
||||
|
||||
- `bash_stop` added to the mutating loop list (`src/permission.ts:223`).
|
||||
- `subjectOf` (`src/permission.ts:306`) maps `bash_stop` → `'bash'` so existing
|
||||
bash rules apply (e.g. a blanket allow on `bash` covers stop).
|
||||
|
||||
## Session / UI
|
||||
|
||||
- `src/session.ts` streams background command output through the existing
|
||||
`onBashOutput` listener (live output panel in interactive mode, same as a
|
||||
foreground command's streaming).
|
||||
- `src/ui/App.tsx`: render the `[bg N]` prefix from the `bash` tool result and
|
||||
make ctrl-c while no foreground command is running stop the most recently
|
||||
started background command (mirror of `interruptBash`). Keeps esc semantics:
|
||||
with a foreground command running, esc still kills it first.
|
||||
- `src/commands.ts`: `/bash` command — `list` (default) shows
|
||||
`handle: cmd (running|exit N)`, `stop <id>` kills, `stop all` kills all.
|
||||
Parser case in `commands.ts`, UI switch in `App.tsx`.
|
||||
- `src/cli.tsx` shutdown: before `process.exit`, call
|
||||
`shutdownBackgrounds()` (async kill live children + write journal).
|
||||
Best-effort — must not throw or delay exit. Journal written to
|
||||
`~/.shiro-neko/backgrounds.json` (SHIRO_HOME-aware via `store.ts` patterns).
|
||||
|
||||
### Prompt guidance
|
||||
|
||||
- `src/prompt.ts` bash tool descriptions: note that long-lived commands
|
||||
(dev servers, watchers, tests that run forever, `bun dev`) should use
|
||||
`background: true` and then be polled with `bash_status` and stopped with
|
||||
`bash_stop` when done. Instruct the model to always stop what it starts.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/tools.ts` — bash background branch, `bash_status`, `bash_stop`,
|
||||
registry entries, `bgHandle` counter, `backgrounds` map, `killBackground`.
|
||||
- `src/tool-utils.ts` — no change (meta derives).
|
||||
- `src/permission.ts` — mutating list + `subjectOf` + DEFAULT_PERMISSIONS.
|
||||
- `src/prompt.ts` — tool descriptions / guidance (bash description + status/stop).
|
||||
- `src/session.ts` — listener wiring (streaming bg output) if not already
|
||||
covered by `onBashOutput`; nothing else needed.
|
||||
- `src/commands.ts` — `/bash` command definition + parser case.
|
||||
- `src/ui/App.tsx` — `/bash` switch case + ctrl-c background fallback.
|
||||
- `src/cli.tsx` — shutdown reaping + journal.
|
||||
- `test/tools.test.ts` — bg tests.
|
||||
- `test/permission.test.ts` — auto (bash_stop mutating coverage).
|
||||
- `test/commands.test.ts` — `/bash` parse + list/stop.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bun test` (665+ tests, new ones included: `sleep 30` background returns
|
||||
immediately; status shows running then finished after `exit 0`; stop kills;
|
||||
journal + reap on shutdown; `/bash list/stop` parse).
|
||||
- `bun run typecheck`.
|
||||
- `bun run build` (must pass `--production`; `dist/shiro --version`).
|
||||
- Manual: `SHIRO_HOME=$(mktemp -d) bun run src/cli.tsx -p "run a dev server in
|
||||
the background and check it is up, then stop it" --yolo --json`.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- Old journal entries (from crashed sessions) are reaped on next boot: at
|
||||
startup, kill stale PIDs or ignore missing ones. Do not leak orphan dev
|
||||
servers across sessions.
|
||||
- `bash_status` poll output is capped (context safety).
|
||||
- Windows: background is best-effort (no process group / setsid semantics);
|
||||
foreground behavior unchanged.
|
||||
- Background processes are not snapshot / undo targets; killing on shutdown
|
||||
is deliberate to avoid orphan servers the user cannot see.
|
||||
@@ -47,7 +47,7 @@ Written by `/provider`, editable by hand. Every field is optional.
|
||||
| `maxRetries` | retries per model call for transient failures. Default 3 |
|
||||
| `maxSpendUsd` | session spend ceiling: warn at 80%, refuse the next turn at 100%. Headless exits non-zero naming the ceiling. Only enforced on priced models |
|
||||
| `maxSpendPerTurn` | per-turn spend ceiling: a single turn past this line is stopped at a step boundary, even when the session ceiling is far away. Only enforced on priced models |
|
||||
| `workflow` | project-driven workflow: `{ "enabled": true, "docsDir": "docs" }`. When the repo has TODO.md/ROADMAP.md/docs, the agent's prompt carries a workflow policy and the session nudges once when files change without the task list being updated. See [docs/workflow.md](workflow.md) |
|
||||
| `workflow` | project-driven workflow: `{ "enabled": true, "docsDir": "docs", "autoScaffold": true }`. When the repo has TODO.md/ROADMAP.md/docs, the agent's prompt carries a workflow policy and the session nudges once when files change without the task list being updated. When the repo has none, the first turn auto-scaffolds them (model-generated, never overwriting). See [docs/workflow.md](workflow.md) |
|
||||
| `subagentModel` | model id for `explore` subagents, which search rather than reason. Omit to share the parent's model. `/cost` reports subagent spend separately |
|
||||
| `plugins` | which builtin plugins to enable. Omit for `["guard", "secrets", "protect", "time", "no-force-push", "no-net-pipe", "no-root", "no-env-write"]` |
|
||||
| `toolSets` | optional tool sets beyond `core`: `edit-plus`, `nav`, `extra`, `git`, and `net`. Omit for the defaults; `net` is opt-in. See [tools](tools.md) |
|
||||
|
||||
+48
-6
@@ -16,7 +16,7 @@ reaches the context is on the wire and in the session file, and there is no taki
|
||||
`*.env.example` is allowed.
|
||||
|
||||
**Asked by default.** `write_file`, `edit_file`, `multi_edit`, `apply_patch`, `move_file`,
|
||||
`delete_file`, `bash`, `web_fetch`, and every `mcp__*` tool.
|
||||
`delete_file`, `bash`, `bash_stop`, `web_fetch`, and every `mcp__*` tool.
|
||||
|
||||
```
|
||||
bash wants to run
|
||||
@@ -66,7 +66,7 @@ Sets let you switch off what a project does not need:
|
||||
|
||||
| Set | Tools | Cost |
|
||||
|---|---|---|
|
||||
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` | ~2,993 B |
|
||||
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` `bash_status` `bash_stop` | ~3,200 B |
|
||||
| `edit-plus` | `multi_edit` `list_dir` `read_many_files` `apply_patch` `move_file` `delete_file` | patch and file ops |
|
||||
| `nav` | `find_symbol` `json_query` | navigation and structured reads |
|
||||
| `extra` | 20 tools: line edits, fs inspect, git extensions, code/env reads | on by default |
|
||||
@@ -357,8 +357,10 @@ reported as `Invalid regex: <reason>` rather than returning an empty result set.
|
||||
### `bash`
|
||||
|
||||
```
|
||||
command shell command
|
||||
timeout ms, default 120000, max 600000
|
||||
command shell command
|
||||
timeout ms, default 120000, max 600000 (ignored when background is true)
|
||||
background detach the command and return immediately with a handle
|
||||
name label for a background command, shown in /bash and bash_status
|
||||
```
|
||||
|
||||
Runs in the workspace root through `bash -lc` or `cmd /c`. Output streams live to the panel
|
||||
@@ -380,7 +382,47 @@ stdout:
|
||||
```
|
||||
|
||||
The turn continues from there. `esc` still aborts everything, and `ctrl-c` with nothing
|
||||
running quits as usual.
|
||||
running quits as usual. When a background command is running and nothing foreground is in
|
||||
flight, `ctrl-c` stops the most recently started one instead of quitting — an accidental quit
|
||||
must not kill a dev server the user still wants.
|
||||
|
||||
### `bash` background mode (dev servers, watchers)
|
||||
|
||||
A command that does not exit — `bun dev`, a watcher, a test suite that never returns — blocks
|
||||
the tool until its timeout, which looks like the agent is stuck. Set `background: true` instead:
|
||||
|
||||
```
|
||||
command: bun dev
|
||||
background: true
|
||||
name: dev server
|
||||
```
|
||||
|
||||
The tool returns immediately with a handle:
|
||||
|
||||
```
|
||||
background 1: running (pid 4821) — poll with bash_status handle=1, stop with bash_stop handle=1
|
||||
command: bun dev
|
||||
```
|
||||
|
||||
The command runs **detached** (its own process group), so it keeps running while the agent
|
||||
works, ctrl-c in the agent does not signal it, and the model can poll it and keep going:
|
||||
|
||||
- **`bash_status`** `handle` — whether it is still running, its exit code when finished, and
|
||||
any output produced since the last status. Poll this while working.
|
||||
- **`bash_stop`** `handle` — kill it, awaited so the process really is gone.
|
||||
|
||||
```
|
||||
$ bash_status handle: 1
|
||||
handle 1: dev server
|
||||
status: running
|
||||
new output:
|
||||
VITE ready in 312 ms
|
||||
```
|
||||
|
||||
Background commands are reaped when the agent exits (killed and removed), and anything left
|
||||
over from a crashed session is killed at the next boot, so a dev server an agent started cannot
|
||||
linger unnoticed. `/bash` lists what is running, `/bash stop <id>` and `/bash stop all` stop
|
||||
them. Like any `bash`, they never go through the file-snapshot/undo system.
|
||||
|
||||
## `web_fetch`
|
||||
|
||||
@@ -578,7 +620,7 @@ Any single tool result is truncated at 30,000 characters with a note saying how
|
||||
| `list_dir` | 300 entries |
|
||||
| `read_many_files` | 20 files |
|
||||
| `read_file` | 2,000 lines by default |
|
||||
| `bash` | 120 s default timeout, 600 s max |
|
||||
| `bash` | 120 s default timeout, 600 s max (background mode has no timeout) |
|
||||
|
||||
Without caps one `grep` for `function` can end a session. The caps are per call, so a model
|
||||
that needs more can narrow and ask again — which is cheaper than one call that fills the
|
||||
|
||||
+30
-2
@@ -27,6 +27,31 @@ Bare repos (no TODO, ROADMAP, or docs) get no such block — the policy only
|
||||
renders when the project itself tracks progress, so a throwaway directory does
|
||||
not collect noise.
|
||||
|
||||
## Auto-scaffolding
|
||||
|
||||
When you run shiro against an *existing* repo that has none of the tracking
|
||||
files (no TODO.md, no ROADMAP.md, no `docs/`, no AGENTS.md), the first turn
|
||||
bootstraps them automatically: the agent investigates the repo and writes
|
||||
project-specific TODO.md, ROADMAP.md, `docs/README.md`, and AGENTS.md before
|
||||
answering. A notice reports what was written:
|
||||
|
||||
```
|
||||
scaffolded project workflow files: TODO.md, ROADMAP.md, docs/, AGENTS.md
|
||||
```
|
||||
|
||||
- **Never overwrites.** Any existing tracker (TODO.md, ROADMAP.md, `docs/`, or
|
||||
AGENTS.md) at the git root means the repo already tracks itself — nothing is
|
||||
created or touched.
|
||||
- **Model-driven content.** The files use real project content (commands,
|
||||
layout, conventions verified against the code) like `/init` does for
|
||||
AGENTS.md. If the model call fails, it degrades to the empty-template
|
||||
scaffold so the turn is never interrupted.
|
||||
- **Write at the git root**, not the cwd — matches where the policy looks.
|
||||
- Runs **once per session**, before the first real turn, so the policy and the
|
||||
first nudge already see the files.
|
||||
- The manual `/init` command still exists for when you want to write AGENTS.md
|
||||
(and scaffold the trackers) on demand.
|
||||
|
||||
TODO.md and ROADMAP.md are also loaded into the conversation like instruction
|
||||
files (`Project tracker (...)`), capped tighter than AGENTS.md so the agent
|
||||
sees the shape of the work without filling its context. This mirrors the
|
||||
@@ -56,11 +81,14 @@ Design constraints:
|
||||
|
||||
```yaml
|
||||
workflow:
|
||||
enabled: true # master switch; default true
|
||||
docsDir: docs # where the project keeps developer docs; default 'docs'
|
||||
enabled: true # master switch; default true
|
||||
docsDir: docs # where the project keeps developer docs; default 'docs'
|
||||
autoScaffold: true # write TODO/ROADMAP/docs/AGENTS.md in a bare repo on first turn; default true
|
||||
```
|
||||
|
||||
`workflow.enabled: false` disables both the prompt policy and the nudge.
|
||||
`workflow.autoScaffold: false` disables only the auto-bootstrap (the policy and
|
||||
nudge still engage when the repo already tracks progress).
|
||||
|
||||
## /workflow
|
||||
|
||||
|
||||
+28
@@ -23,6 +23,7 @@ import * as registry from './registry';
|
||||
import { Session } from './session';
|
||||
import { loadCustomCommands } from './custom-commands';
|
||||
import { loadSkills } from './skills';
|
||||
import { reapStaleBackgrounds, shutdownBackgrounds, backgroundSummary, stopBackground } from './tools';
|
||||
import * as store from './store';
|
||||
import { createTaskTool, type SubagentApproval } from './subagent';
|
||||
import { VERSION, versionLine } from './version';
|
||||
@@ -327,6 +328,10 @@ const subagentModel =
|
||||
// wired after construction.
|
||||
let recordSubagent: (usage: { inputTokens: number; outputTokens: number }) => void = () => {};
|
||||
|
||||
// Orphaned background commands from a crashed session: kill them if they are
|
||||
// still alive, so a dev server a dead agent started does not linger.
|
||||
reapStaleBackgrounds();
|
||||
|
||||
const session = new Session({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
modelId: cfg.model,
|
||||
@@ -398,6 +403,13 @@ async function shutdown(code: number): Promise<never> {
|
||||
clearTimeout(saveTimer);
|
||||
if (session.messages.length > 0) await persist(session.messages);
|
||||
await mcp?.close();
|
||||
// Dev servers and watchers started in the background must not outlive the
|
||||
// agent silently; reap them (best-effort, never blocks exit).
|
||||
try {
|
||||
await shutdownBackgrounds();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
const printArg = flag('-p', '--print');
|
||||
@@ -562,6 +574,22 @@ const hooks: AppHooks = {
|
||||
return `removed mcp server ${name}\nsaved to ${path}\nrestart shiro to disconnect it`;
|
||||
},
|
||||
},
|
||||
backgroundCommands: {
|
||||
list: () => {
|
||||
const rows = backgroundSummary();
|
||||
if (rows.length === 0) return 'no background commands';
|
||||
return rows
|
||||
.map((r) => `${r.handle}: ${r.name} — ${r.running ? 'running' : `exit ${r.exit}`}\n ${r.command}`)
|
||||
.join('\n');
|
||||
},
|
||||
stop: async (handle) => stopBackground(handle),
|
||||
stopAll: async () => {
|
||||
const rows = backgroundSummary();
|
||||
if (rows.length === 0) return 'no background commands';
|
||||
const results = await Promise.all(rows.map((r) => stopBackground(r.handle)));
|
||||
return results.join('\n');
|
||||
},
|
||||
},
|
||||
initPrompt: INIT_PROMPT,
|
||||
scaffoldWorkflow: () => scaffoldWorkflowFiles(),
|
||||
history: promptHistory,
|
||||
|
||||
@@ -29,6 +29,7 @@ export type CommandAction =
|
||||
| { type: 'undo' }
|
||||
| { type: 'redo' }
|
||||
| { type: 'changes' }
|
||||
| { type: 'bash'; action: 'list' | 'stop' | 'stop-all'; arg?: string }
|
||||
| { type: 'search'; query: string }
|
||||
| { type: 'fork' }
|
||||
| { type: 'workflow' }
|
||||
@@ -70,6 +71,7 @@ export const COMMANDS: CommandSpec[] = [
|
||||
{ name: 'undo', summary: 'undo the last turn — restores files and conversation (bash effects are not snapshotted)' },
|
||||
{ name: 'redo', summary: 'redo the last undone turn' },
|
||||
{ name: 'changes', summary: 'show what the last turn changed on disk' },
|
||||
{ name: 'bash', arg: '[list|stop <id>|stop all]', summary: 'list or stop background commands started with bash background: true' },
|
||||
{ name: 'search', arg: '<query>', summary: 'search saved sessions for a phrase' },
|
||||
{ name: 'fork', summary: 'fork the session at the last turn boundary (keeps the original)' },
|
||||
{ name: 'workflow', summary: 'show project workflow state: TODO/ROADMAP tracking, docs, nudges' },
|
||||
@@ -243,6 +245,18 @@ export function parseCommand(raw: string, custom: readonly CustomCommand[] = [])
|
||||
return { type: 'redo' };
|
||||
case 'changes':
|
||||
return { type: 'changes' };
|
||||
case 'bash': {
|
||||
const [verb = '', ...rest] = arg.split(/\s+/);
|
||||
if (verb === 'stop') {
|
||||
if (rest.join(' ').trim().toLowerCase() === 'all') return { type: 'bash', action: 'stop-all' };
|
||||
const id = Number(rest[0]);
|
||||
return Number.isInteger(id) && id > 0
|
||||
? { type: 'bash', action: 'stop', arg: String(id) }
|
||||
: { type: 'info', text: 'usage: /bash stop <id> or /bash stop all' };
|
||||
}
|
||||
if (verb && verb !== 'list') return { type: 'info', text: 'usage: /bash [list|stop <id>|stop all]' };
|
||||
return { type: 'bash', action: 'list' };
|
||||
}
|
||||
case 'search':
|
||||
return arg ? { type: 'search', query: arg } : { type: 'info', text: 'usage: /search <query>' };
|
||||
case 'fork':
|
||||
|
||||
@@ -31,6 +31,8 @@ export type Config = {
|
||||
enabled?: boolean;
|
||||
/** Directory the project keeps its developer docs in, for the docs-driven rule. Default 'docs'. */
|
||||
docsDir?: string;
|
||||
/** Auto-write TODO.md/ROADMAP.md/docs/AGENTS.md on first turn when the repo has none. Default true. */
|
||||
autoScaffold?: boolean;
|
||||
};
|
||||
/** Model id for subagents; omit to share the parent's. */
|
||||
subagentModel?: string;
|
||||
@@ -132,6 +134,7 @@ export async function loadConfig(): Promise<Config> {
|
||||
workflow: {
|
||||
...(typeof file.workflow.enabled === 'boolean' ? { enabled: file.workflow.enabled } : {}),
|
||||
...(file.workflow.docsDir ? { docsDir: file.workflow.docsDir } : {}),
|
||||
...(typeof file.workflow.autoScaffold === 'boolean' ? { autoScaffold: file.workflow.autoScaffold } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
+5
-1
@@ -61,6 +61,10 @@ export function subjectOf(tool: string, input: unknown): string | undefined {
|
||||
switch (tool) {
|
||||
case 'bash':
|
||||
return str('command');
|
||||
case 'bash_stop':
|
||||
// Stopping a background command is gated like the command that started it,
|
||||
// so an approved `bash` rule also covers stopping what it started.
|
||||
return str('handle');
|
||||
case 'mcp_call': {
|
||||
const server = str('server');
|
||||
const toolName = str('tool');
|
||||
@@ -220,7 +224,7 @@ function buildDefaults(): PermissionConfig {
|
||||
}
|
||||
} catch {
|
||||
// tests that import permission in isolation still get BASE + known mutating fallback
|
||||
for (const name of ['write_file','edit_file','multi_edit','apply_patch','move_file','delete_file','insert_lines','delete_lines','replace_lines','append_file','prepend_file','bash','mcp_call'] as const) {
|
||||
for (const name of ['write_file','edit_file','multi_edit','apply_patch','move_file','delete_file','insert_lines','delete_lines','replace_lines','append_file','prepend_file','bash','bash_stop','mcp_call'] as const) {
|
||||
if (!(name in out)) (out as Record<string, PermissionEntry>)[name] = 'ask';
|
||||
}
|
||||
}
|
||||
|
||||
+118
-1
@@ -1,5 +1,9 @@
|
||||
import { join } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { streamText } from 'ai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
|
||||
/** True when the repo holds the tracking files the workflow expects. */
|
||||
|
||||
/**
|
||||
* Scaffolds the project-workflow files when a repo has none.
|
||||
@@ -80,4 +84,117 @@ export function scaffoldWorkflowFiles(cwd = process.cwd()): string[] {
|
||||
}
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of workflow files the agent treats as "the project tracks itself".
|
||||
* Auto-scaffolding only runs when none of these exist — a repo that already
|
||||
* tracks progress keeps what it has.
|
||||
*/
|
||||
const TRACKING_NAMES = ['TODO.md', 'ROADMAP.md', 'AGENTS.md'] as const;
|
||||
|
||||
/** True when any tracking/instruction file exists at the git root. */
|
||||
export function projectTracksAt(root: string, docsDir = 'docs'): boolean {
|
||||
for (const name of TRACKING_NAMES) {
|
||||
if (existsSync(join(root, name))) return true;
|
||||
}
|
||||
return existsSync(join(root, docsDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the workflow files a project-driven session expects when the repo
|
||||
* has none — before the first turn, so the system prompt's workflow policy and
|
||||
* the very first nudge see them. Uses the model to write project-specific
|
||||
* content (TODO.md, ROADMAP.md, docs/, AGENTS.md). Never overwrites.
|
||||
*
|
||||
* Returns the relative paths written. On model failure it degrades to the
|
||||
* empty-template scaffold so the *session* never fails — a missing model
|
||||
* should not break a turn.
|
||||
*/
|
||||
export async function scaffoldMissingAuto(
|
||||
root: string,
|
||||
model: LanguageModel,
|
||||
opts: { docsDir?: string; maxRetries?: number } = {},
|
||||
): Promise<string[]> {
|
||||
const docsDir = opts.docsDir ?? 'docs';
|
||||
const todo = join(root, 'TODO.md');
|
||||
const roadmap = join(root, 'ROADMAP.md');
|
||||
const docs = join(root, docsDir);
|
||||
const agents = join(root, 'AGENTS.md');
|
||||
|
||||
const hasTodo = existsSync(todo);
|
||||
const hasRoadmap = existsSync(roadmap);
|
||||
const hasDocs = existsSync(docs);
|
||||
const hasAgents = existsSync(agents);
|
||||
// A repo that already tracks anything is left alone.
|
||||
if (hasTodo || hasRoadmap || hasDocs || hasAgents) return [];
|
||||
|
||||
const missing = {
|
||||
todo: !hasTodo,
|
||||
roadmap: !hasRoadmap,
|
||||
docs: !hasDocs,
|
||||
agents: !hasAgents,
|
||||
};
|
||||
|
||||
// One model call for the whole set keeps it cheap; the prompt asks for real
|
||||
// content derived from the repo (the /init prompt does the same for AGENTS.md).
|
||||
// Uses a stream so it works with any model (mocks included) — a text-only
|
||||
// generateText would need doGenerate, which not every model implements.
|
||||
let content = '';
|
||||
try {
|
||||
const missingList = [
|
||||
missing.todo ? 'TODO.md — a task list: Now / Next / Maintenance sections, one item per line' : '',
|
||||
missing.roadmap ? 'ROADMAP.md — where the project is heading: Next section, what is built and what is deliberately declined' : '',
|
||||
missing.docs ? 'docs/ with a README.md — developer documentation' : '',
|
||||
missing.agents ? 'AGENTS.md — orientation for a coding agent joining cold: what the project is, install/build/test/typecheck commands, layout, conventions, surprising things' : '',
|
||||
].filter(Boolean).join('; ');
|
||||
|
||||
const { textStream: ts } = await streamText({
|
||||
model,
|
||||
system:
|
||||
'You are bootstrapping project workflow files for a codebase that has none. ' +
|
||||
'Write only the files, with real content derived from the repo. Keep each file concise and honest: ' +
|
||||
'TODO.md (Now/Next/Maintenance), ROADMAP.md (Next), docs/README.md, AGENTS.md. ' +
|
||||
'Do not invent commands you cannot verify. Output must be plain text with no markdown fences — ' +
|
||||
'the exact file contents for each file, separated by a line that reads exactly: ===FILE <path>===',
|
||||
prompt: `Look at this repo and write the missing workflow files. Missing: ${missingList}.`,
|
||||
maxRetries: opts.maxRetries ?? 3,
|
||||
});
|
||||
for await (const chunk of ts) content += chunk;
|
||||
} catch {
|
||||
// fall through to the empty-template scaffold below
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const written: string[] = [];
|
||||
// Split on the ===FILE <path>=== marker; each block is one file.
|
||||
const blocks = content.split(/^===FILE\s+(.+?)\s*===$/m);
|
||||
for (let i = 1; i + 1 < blocks.length; i += 2) {
|
||||
const rel = blocks[i]!.trim();
|
||||
const body = blocks[i + 1]!.trim();
|
||||
if (!(rel.startsWith('TODO.md') || rel.startsWith('ROADMAP.md') || rel.startsWith('docs/') || rel === 'AGENTS.md')) continue;
|
||||
const abs = join(root, rel);
|
||||
if (existsSync(abs)) continue;
|
||||
try {
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
writeFileSync(abs, body.endsWith('\n') ? body : body + '\n');
|
||||
written.push(rel);
|
||||
} catch {
|
||||
// a read-only workspace keeps what it has
|
||||
}
|
||||
}
|
||||
// If the model produced at least one real file, that's the win; fill any
|
||||
// still-missing standard files with the empty templates.
|
||||
if (written.length > 0) {
|
||||
const templated = scaffoldWorkflowFiles(root);
|
||||
const union = [...written];
|
||||
for (const t of templated) {
|
||||
if (!union.includes(t)) union.push(t);
|
||||
}
|
||||
return union;
|
||||
}
|
||||
}
|
||||
|
||||
// Model failed or wrote nothing: plain empty templates, never fail the turn.
|
||||
return scaffoldWorkflowFiles(root);
|
||||
}
|
||||
+43
-4
@@ -121,6 +121,8 @@ export type SessionOptions = {
|
||||
enabled?: boolean;
|
||||
/** Where the project keeps developer docs. Default 'docs'. */
|
||||
docsDir?: string;
|
||||
/** Auto-write TODO.md/ROADMAP.md/docs/AGENTS.md on the first turn when the repo has none. Default true. */
|
||||
autoScaffold?: boolean;
|
||||
};
|
||||
/** Disable background auto-learn (tests). */
|
||||
disableAutoLearn?: boolean;
|
||||
@@ -247,8 +249,12 @@ export class Session {
|
||||
private turnCappedNotice: string | undefined;
|
||||
/** Did the current turn call todo_write? Gates the workflow nudge. */
|
||||
private todoWrittenThisTurn = false;
|
||||
/** Did this turn actually write a file? Set by onBeforeWrite, reset in finally. */
|
||||
private turnWrote = false;
|
||||
/** How many times this session has nudged about the task list; capped at 3. */
|
||||
private workflowNudgeCount = 0;
|
||||
/** Auto-scaffold runs once per session on the first turn; this prevents a repeat. */
|
||||
private autoScaffoldDone = false;
|
||||
/** Line count of TODO.md at last check, for /workflow. */
|
||||
private workflowTodoLines = 0;
|
||||
private workflowRoadmapLines = 0;
|
||||
@@ -536,9 +542,10 @@ export class Session {
|
||||
// refreshed after the turn; compare at turn end: if no onBeforeWrite
|
||||
// fired, this turn changed nothing — no nudge.
|
||||
if (this.fileChangeSeq <= this.lastWalkSeq && this.fileChangeSeq === 0) return undefined;
|
||||
// Only when onBeforeWrite actually fired (a write succeeded) and no todo_write
|
||||
const hasWrites = this.turnBeforeFiles?.size > 0;
|
||||
if (!hasWrites) return undefined;
|
||||
// Only when onBeforeWrite actually fired (a write succeeded) — a turn whose
|
||||
// edits all failed must not consume a nudge. turnWrote is set by the hook
|
||||
// and reset in finally, so it reflects exactly the current turn.
|
||||
if (!this.turnWrote) return undefined;
|
||||
this.workflowNudgeCount += 1;
|
||||
const messages = [
|
||||
'reminder: you modified files without updating the project task list (TODO.md). Keep it current: mark what you did.',
|
||||
@@ -919,6 +926,31 @@ export class Session {
|
||||
}
|
||||
|
||||
async *send(userText: string): AsyncGenerator<AgentEvent> {
|
||||
// On the first turn of a fresh session in an existing repo with no tracking
|
||||
// files, bootstrap the workflow files (TODO.md / ROADMAP.md / docs/ / AGENTS.md)
|
||||
// so the policy and nudges engage immediately. Runs once, before the model's
|
||||
// real turn, and never fails the turn (degraded inside scaffoldMissingAuto).
|
||||
if (!this.autoScaffoldDone && this.opts.workflow?.enabled !== false && this.opts.workflow?.autoScaffold !== false) {
|
||||
this.autoScaffoldDone = true;
|
||||
try {
|
||||
const root = this.gitRoot();
|
||||
if (root) {
|
||||
const { scaffoldMissingAuto } = await import('./scaffold');
|
||||
const written = await scaffoldMissingAuto(root, this.model, {
|
||||
docsDir: this.opts.workflow?.docsDir,
|
||||
});
|
||||
if (written.length > 0) {
|
||||
// The policy now finds the tracker files; drop the stale prompt cache
|
||||
// and re-render so this turn's system prompt already carries them.
|
||||
this.promptCache = undefined;
|
||||
this.versions.workflow = (this.versions.workflow ?? 0) + 1;
|
||||
yield { type: 'notice', text: `scaffolded project workflow files: ${written.join(', ')}` };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// scaffolding must never break a turn
|
||||
}
|
||||
}
|
||||
// The ceiling is checked before the model is: a turn started past the limit
|
||||
// would spend money the caller said not to. An unpriced model cannot be
|
||||
// measured, so it is never refused here — the ceiling simply cannot see it.
|
||||
@@ -940,6 +972,7 @@ export class Session {
|
||||
this.turnStartUsd = this.spend().usd;
|
||||
this.turnCappedNotice = undefined;
|
||||
this.todoWrittenThisTurn = false;
|
||||
this.turnWrote = false;
|
||||
onBeforeWrite(async (abs: string) => {
|
||||
if (this.turnBeforeFiles.has(abs)) return;
|
||||
const exists = await Bun.file(abs).exists();
|
||||
@@ -949,6 +982,7 @@ export class Session {
|
||||
}
|
||||
this.turnBeforeFiles.set(abs, { existed: exists, content });
|
||||
this.fileChangeSeq += 1;
|
||||
this.turnWrote = true;
|
||||
});
|
||||
this.messages.push({ role: 'user', content: userText });
|
||||
this.opts.onChange?.(this.messages);
|
||||
@@ -999,12 +1033,17 @@ export class Session {
|
||||
}
|
||||
} catch {}
|
||||
this.turnBeforeFiles = new Map<string, FileState>();
|
||||
this.turnWrote = false;
|
||||
this.controller = undefined;
|
||||
this.drainPendingHotReload();
|
||||
// Files written this turn are now on disk; re-walk so the next prompt's
|
||||
// workspace list shows them without a restart.
|
||||
try { await this.refreshWorkspaceFiles(); } catch {}
|
||||
onBashOutput(undefined);
|
||||
// Keep the listener alive while a background process is running so the UI
|
||||
// panel shows its output between turns. statusBackground always has the
|
||||
// data either way (bg.tail); this just keeps the live panel up.
|
||||
const { backgroundHandles } = await import('./tools');
|
||||
if (backgroundHandles().length === 0) onBashOutput(undefined);
|
||||
await (this.pluginHost ?? this.opts.plugins)?.afterTurn();
|
||||
if (!this.opts.disableAutoLearn && this.messages.length >= 6) {
|
||||
this.learnTurns += 1;
|
||||
|
||||
+237
-4
@@ -1,6 +1,6 @@
|
||||
import { tool } from 'ai';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { jail, posix, walk } from './ignore';
|
||||
import { recordBeforeWrite } from './snapshot';
|
||||
@@ -585,6 +585,206 @@ type Running = { command: string; proc: Bun.Subprocess; interrupted: boolean; ki
|
||||
|
||||
const running = new Map<string, Running>();
|
||||
|
||||
/** Best-effort path for the background-process journal, resolved per call (SHIRO_HOME may move). */
|
||||
function bgJournalPath(): string {
|
||||
const home = process.env['SHIRO_HOME'] ?? join(process.env['HOME'] ?? '', '.shiro-neko');
|
||||
return join(home, 'backgrounds.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* A detached background command (dev server, watcher, long test).
|
||||
*
|
||||
* Unlike `running` (foreground, killed by ctrl-c), a background command is
|
||||
* setsid'd so the agent process can exit without taking it down, and ctrl-c in
|
||||
* the agent does not signal it. It lives until `bash_stop`, or until the agent
|
||||
* exits and reaps it (see `shutdownBackgrounds`) — a stray dev server from a
|
||||
* crashed session must not linger, so the journal exists for exactly that case.
|
||||
*/
|
||||
type Background = {
|
||||
handle: number;
|
||||
command: string;
|
||||
name: string;
|
||||
proc: Bun.Subprocess;
|
||||
exited: Promise<number | null>;
|
||||
/** Fresh output since the last `bash_status`. Append-only, capped by MAX_OUTPUT. */
|
||||
tail: string;
|
||||
/** How much of `tail` the last status read already showed; the rest is "new". */
|
||||
shown: number;
|
||||
};
|
||||
|
||||
let nextHandle = 1;
|
||||
const backgrounds = new Map<number, Background>();
|
||||
const BY_NAME = new Map<string, number>();
|
||||
|
||||
export function backgroundHandles(): number[] {
|
||||
return [...backgrounds.keys()].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function backgroundSummary(): { handle: number; name: string; command: string; running: boolean; exit: number | null }[] {
|
||||
return [...backgrounds.values()]
|
||||
.sort((a, b) => a.handle - b.handle)
|
||||
.map((b) => ({
|
||||
handle: b.handle,
|
||||
name: b.name,
|
||||
command: b.command,
|
||||
running: !b.proc.exited,
|
||||
exit: b.proc.exitCode,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a detached background command and returns its handle.
|
||||
*
|
||||
* Output streams into the per-handle tail buffer (and through the bash listener
|
||||
* for live UI progress). The process is adopted by a cleanup crawler at boot:
|
||||
* anything the journal lists that is still alive is killed (see below).
|
||||
*/
|
||||
export function startBackground(command: string, name: string): number {
|
||||
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
|
||||
let proc: Bun.Subprocess;
|
||||
try {
|
||||
proc = Bun.spawn(shell, {
|
||||
cwd: process.cwd(),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
detached: true,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`could not start background command: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
const handle = nextHandle++;
|
||||
const bg: Background = {
|
||||
handle,
|
||||
command,
|
||||
name: name || command.split(/\s+/)[0] || 'command',
|
||||
proc,
|
||||
exited: proc.exited,
|
||||
tail: '',
|
||||
shown: 0,
|
||||
};
|
||||
backgrounds.set(handle, bg);
|
||||
if (name) BY_NAME.set(name, handle);
|
||||
|
||||
const pump = async (stream: ReadableStream<Uint8Array> | undefined) => {
|
||||
if (!stream) return;
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of stream) {
|
||||
const text = decoder.decode(chunk, { stream: true });
|
||||
if (!text) continue;
|
||||
bg.tail = (bg.tail + text).slice(-MAX_OUTPUT * 2);
|
||||
bashListener?.({ toolCallId: `bg${handle}`, chunk: text });
|
||||
}
|
||||
};
|
||||
void pump(proc.stdout as ReadableStream<Uint8Array>);
|
||||
void pump(proc.stderr as ReadableStream<Uint8Array>);
|
||||
void proc.exited.then(() => {
|
||||
try {
|
||||
writeJournal();
|
||||
} catch {
|
||||
// journal is best-effort
|
||||
}
|
||||
});
|
||||
|
||||
writeJournal();
|
||||
return handle;
|
||||
}
|
||||
|
||||
function writeJournal(): void {
|
||||
try {
|
||||
const dir = dirname(bgJournalPath());
|
||||
const { mkdirSync, writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const rows = [...backgrounds.values()].map((b) => ({
|
||||
handle: b.handle,
|
||||
pid: b.proc.pid,
|
||||
command: b.command,
|
||||
name: b.name,
|
||||
alive: b.proc.exitCode === null,
|
||||
}));
|
||||
writeFileSync(bgJournalPath(), JSON.stringify(rows, null, 2));
|
||||
} catch {
|
||||
// best-effort; a failed journal write must not break a command start
|
||||
}
|
||||
}
|
||||
|
||||
/** Stale entries from a crashed session: if still alive, kill them (best-effort). */
|
||||
export function reapStaleBackgrounds(): void {
|
||||
try {
|
||||
const { readFileSync, existsSync } = require('node:fs') as typeof import('node:fs');
|
||||
const p = bgJournalPath();
|
||||
if (!existsSync(p)) return;
|
||||
const rows = JSON.parse(readFileSync(p, 'utf8')) as { pid?: number }[];
|
||||
for (const row of rows) {
|
||||
if (typeof row.pid !== 'number' || row.pid <= 0) continue;
|
||||
try {
|
||||
process.kill(row.pid, 0); // throws if the pid is not ours / gone
|
||||
process.kill(row.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// already gone or not ours; nothing to reap
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { unlinkSync } = require('node:fs') as typeof import('node:fs');
|
||||
unlinkSync(p);
|
||||
} catch {
|
||||
// fine
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills every live background process and clears the journal. Called on agent
|
||||
* shutdown so a dev server started by a session does not outlive it silently.
|
||||
*/
|
||||
export async function shutdownBackgrounds(): Promise<void> {
|
||||
const live = [...backgrounds.values()].filter((b) => b.proc.exitCode === null);
|
||||
const kills = live.map((b) => {
|
||||
b.proc.kill();
|
||||
return b.proc.exited;
|
||||
});
|
||||
await Promise.allSettled(kills);
|
||||
backgrounds.clear();
|
||||
BY_NAME.clear();
|
||||
try {
|
||||
const { unlinkSync } = require('node:fs') as typeof import('node:fs');
|
||||
unlinkSync(bgJournalPath());
|
||||
} catch {
|
||||
// file already gone
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops one background command by handle. Returns a human-readable result. */
|
||||
export async function stopBackground(handle: number): Promise<string> {
|
||||
const bg = backgrounds.get(handle);
|
||||
if (!bg) return `no such handle: ${handle}`;
|
||||
bg.proc.kill();
|
||||
await bg.proc.exited;
|
||||
backgrounds.delete(handle);
|
||||
if (bg.name) BY_NAME.delete(bg.name);
|
||||
writeJournal();
|
||||
return `killed ${handle}: ${bg.command}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary a `bash_status` call returns: the process state, recent output,
|
||||
* and whether there is output newer than the last status the model read (so a
|
||||
* poll sees progress without re-reading the whole buffer).
|
||||
*/
|
||||
export function statusBackground(handle: number): string {
|
||||
const bg = backgrounds.get(handle);
|
||||
if (!bg) return `status: no such handle ${handle}`;
|
||||
const lines = bg.tail.split('\n');
|
||||
const shown = bg.shown;
|
||||
const freshLines = lines.slice(Math.max(0, lines.length - shown)).join('\n').trim();
|
||||
bg.shown = lines.length;
|
||||
const state = bg.proc.exitCode === null ? 'running' : `finished (exit ${bg.proc.exitCode})`;
|
||||
const fresh = freshLines.length > 0 ? `\nnew output:\n${freshLines}` : '\n(new output: none)';
|
||||
return `handle ${handle}: ${bg.name}\nstatus: ${state}${bg.command ? `\ncommand: ${bg.command}` : ''}${fresh}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills the shell and everything it started.
|
||||
*
|
||||
@@ -633,12 +833,22 @@ export function interruptBash(): string[] {
|
||||
export const bashTool = withMeta({ set: 'core', mutating: true }, tool({
|
||||
description:
|
||||
'Run a shell command in the workspace root. Use for builds, tests, git, and package managers. ' +
|
||||
'Output streams live and the user can interrupt a command with ctrl-c without ending the turn.',
|
||||
'Output streams live and the user can interrupt a command with ctrl-c without ending the turn. ' +
|
||||
'For a command that does not exit — a dev server, a watcher, a long-running test — set background: true ' +
|
||||
'so the tool returns immediately with a handle; poll it with bash_status and stop it with bash_stop when done. ' +
|
||||
'Always stop what you start.',
|
||||
inputSchema: z.object({
|
||||
command: z.string(),
|
||||
timeout: z.number().int().min(1000).max(600_000).optional().describe('Timeout in ms, default 120000'),
|
||||
timeout: z.number().int().min(1000).max(600_000).optional().describe('Timeout in ms, default 120000; ignored when background is true'),
|
||||
background: z.boolean().optional().describe('Detach the command and return immediately with a handle. Use for dev servers and watchers that never exit'),
|
||||
name: z.string().optional().describe('Label for a background command, shown in /bash and bash_status. Ignored unless background is true'),
|
||||
}),
|
||||
execute: async ({ command, timeout = 120_000 }, { toolCallId, abortSignal }) => {
|
||||
execute: async ({ command, timeout = 120_000, background, name }, { toolCallId, abortSignal }) => {
|
||||
if (background) {
|
||||
const handle = startBackground(command, name ?? '');
|
||||
const pid = [...backgrounds.values()].find((b) => b.handle === handle)?.proc.pid;
|
||||
return `background ${handle}: running (pid ${pid ?? '?'}) — poll with bash_status handle=${handle}, stop with bash_stop handle=${handle}\ncommand: ${command}`;
|
||||
}
|
||||
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
|
||||
const proc = Bun.spawn(shell, {
|
||||
cwd: process.cwd(),
|
||||
@@ -694,6 +904,27 @@ export const bashTool = withMeta({ set: 'core', mutating: true }, tool({
|
||||
},
|
||||
}));
|
||||
|
||||
export const bashStatusTool = withMeta({ set: 'core', mutating: false }, tool({
|
||||
description:
|
||||
'Check a background command started with bash background: true. Pass the handle from that call. ' +
|
||||
'Returns whether it is still running or finished (with its exit code) and any output produced since the last status. ' +
|
||||
'Poll this while working; stop the command with bash_stop when it has served its purpose.',
|
||||
inputSchema: z.object({
|
||||
handle: z.number().int().positive().describe('Handle returned by a background bash call'),
|
||||
}),
|
||||
execute: async ({ handle }) => statusBackground(handle),
|
||||
}));
|
||||
|
||||
export const bashStopTool = withMeta({ set: 'core', mutating: true }, tool({
|
||||
description:
|
||||
'Stop a background command started with bash background: true. Pass the handle from that call. ' +
|
||||
'Use this when the command has done its job (a dev server you no longer need, a watcher, a long test).',
|
||||
inputSchema: z.object({
|
||||
handle: z.number().int().positive().describe('Handle returned by a background bash call'),
|
||||
}),
|
||||
execute: async ({ handle }) => stopBackground(handle),
|
||||
}));
|
||||
|
||||
export const moveFileTool = withMeta({ set: 'edit-plus', mutating: true }, tool({
|
||||
description:
|
||||
'Move or rename one file. Creates the target directory. Refuses if the source is missing or the target ' +
|
||||
@@ -869,6 +1100,8 @@ export const tools = {
|
||||
find_symbol: findSymbolTool,
|
||||
json_query: jsonQueryTool,
|
||||
bash: bashTool,
|
||||
bash_status: bashStatusTool,
|
||||
bash_stop: bashStopTool,
|
||||
...gitTools,
|
||||
...netTools,
|
||||
...extraTools,
|
||||
|
||||
+45
-4
@@ -93,6 +93,12 @@ export type AppHooks = {
|
||||
initPrompt: string;
|
||||
/** Directly scaffold TODO.md / ROADMAP.md / docs/ when they are missing; returns what was written. */
|
||||
scaffoldWorkflow: () => string[];
|
||||
/** Background commands started with bash background: true — list, stop one, stop all. */
|
||||
backgroundCommands: {
|
||||
list: () => string;
|
||||
stop: (handle: number) => Promise<string>;
|
||||
stopAll: () => Promise<string>;
|
||||
};
|
||||
history: string[];
|
||||
recordPrompt: (text: string) => void;
|
||||
};
|
||||
@@ -278,12 +284,28 @@ export function App({
|
||||
// ctrl-c kills only the command in flight, leaving the turn alive so the model
|
||||
// gets a tool error and can decide what to do. With nothing running it keeps its
|
||||
// usual meaning and quits, which is why Ink's own ctrl-c handling is turned off
|
||||
// in cli.tsx rather than left to race with this.
|
||||
useInput((input, key) => {
|
||||
// in cli.tsx rather than left to race with this. When a background command is
|
||||
// running (started by the model with bash background: true) and nothing
|
||||
// foreground is in flight, ctrl-c stops the most recently started one instead
|
||||
// of quitting — an accidental quit killing a dev server the user still wants.
|
||||
useInput(async (input, key) => {
|
||||
if (!key.ctrl || input !== 'c') return;
|
||||
const killed = interruptBash();
|
||||
if (killed.length === 0) return exit();
|
||||
push({ kind: 'info', text: `interrupted: ${killed.join(', ')}` });
|
||||
if (killed.length > 0) {
|
||||
push({ kind: 'info', text: `interrupted: ${killed.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const handles = hooks.backgroundCommands.list();
|
||||
if (handles.trim() !== '' && handles.trim() !== 'no background commands') {
|
||||
const lines = handles.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
const first = lines[0]?.match(/^(\d+):/)?.[1];
|
||||
if (first) {
|
||||
const msg = await hooks.backgroundCommands.stop(Number(first));
|
||||
push({ kind: 'info', text: `ctrl-c: no foreground command; ${msg}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
return exit();
|
||||
});
|
||||
|
||||
useInput(
|
||||
@@ -749,6 +771,25 @@ export function App({
|
||||
setPanel(workflowPanel(session));
|
||||
return;
|
||||
}
|
||||
case 'bash': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
if (action.action === 'list') {
|
||||
push({ kind: 'info', text: hooks.backgroundCommands.list() });
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
try {
|
||||
const text =
|
||||
action.action === 'stop-all'
|
||||
? await hooks.backgroundCommands.stopAll()
|
||||
: await hooks.backgroundCommands.stop(Number(action.arg));
|
||||
push({ kind: 'info', text });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setWorking(false);
|
||||
return;
|
||||
}
|
||||
case 'provider':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setOnboarding(true);
|
||||
|
||||
@@ -158,3 +158,22 @@ test('/registry add with no name returns usage rather than fetching anything', (
|
||||
test('a bare word after /registry is treated as a search', () => {
|
||||
expect(parseCommand('/registry migration')).toEqual({ type: 'registry', action: 'search', arg: 'migration' });
|
||||
});
|
||||
|
||||
test('/bash with no verb lists background commands', () => {
|
||||
expect(parseCommand('/bash')).toEqual({ type: 'bash', action: 'list' });
|
||||
expect(parseCommand('/bash list')).toEqual({ type: 'bash', action: 'list' });
|
||||
});
|
||||
|
||||
test('/bash stop carries the handle', () => {
|
||||
expect(parseCommand('/bash stop 3')).toEqual({ type: 'bash', action: 'stop', arg: '3' });
|
||||
});
|
||||
|
||||
test('/bash stop all stops everything', () => {
|
||||
expect(parseCommand('/bash stop all')).toEqual({ type: 'bash', action: 'stop-all' });
|
||||
});
|
||||
|
||||
test('/bash with an unknown verb returns usage', () => {
|
||||
const out = parseCommand('/bash frobnicate');
|
||||
expect(out.type).toBe('info');
|
||||
expect((out as { text: string }).text).toContain('usage: /bash');
|
||||
});
|
||||
+6
-1
@@ -55,7 +55,12 @@ export function testHooks(over: Partial<AppHooks> = {}): AppHooks {
|
||||
remove: async (name) => `removed ${name}`,
|
||||
},
|
||||
initPrompt: 'write AGENTS.md',
|
||||
scaffoldWorkflow: () => [],
|
||||
scaffoldWorkflow: () => [],
|
||||
backgroundCommands: {
|
||||
list: () => 'no background commands',
|
||||
stop: async (handle) => `stopped ${handle}`,
|
||||
stopAll: async () => 'stopped all',
|
||||
},
|
||||
history: [],
|
||||
recordPrompt: () => {},
|
||||
...over,
|
||||
|
||||
@@ -74,15 +74,17 @@ test('/changes is undefined for a turn that wrote nothing', () =>
|
||||
expect(session.lastTurnSummary()).toBeUndefined();
|
||||
}));
|
||||
|
||||
test('system prompt is memoized until a volatile part changes', () => {
|
||||
test('system prompt is memoized until a volatile part changes', async () => {
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({ doStream: async () => stream(text('ok')) }),
|
||||
askApproval: noop,
|
||||
});
|
||||
// private API is exercised through the public turn loop; assert the cache counts.
|
||||
for (let i = 0; i < 3; i++) void session.estimatedTokens();
|
||||
// force a miss then a few hits via send
|
||||
void drain(session);
|
||||
// force a miss then a few hits via send. Awaited: the turn loop must run to
|
||||
// completion before the stats are read (the auto-scaffold adds an async step
|
||||
// at the top of send, so a fire-and-forget drain races the first prompt build).
|
||||
await drain(session);
|
||||
const stats = session.promptCacheStats();
|
||||
expect(stats.misses).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.hits).toBeGreaterThanOrEqual(0);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||
import type { LanguageModelV4StreamPart } from '@ai-sdk/provider';
|
||||
import { scaffoldMissingAuto, projectTracksAt } from '../src/scaffold';
|
||||
import { usageOf } from './helpers';
|
||||
|
||||
const usage = usageOf(5, 3);
|
||||
|
||||
function textParts(body: string): LanguageModelV4StreamPart[] {
|
||||
return [
|
||||
{ type: 'text-start', id: '0' },
|
||||
{ type: 'text-delta', id: '0', delta: body },
|
||||
{ type: 'text-end', id: '0' },
|
||||
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
|
||||
];
|
||||
}
|
||||
|
||||
function stream(body: string) {
|
||||
const parts = textParts(body);
|
||||
return { stream: simulateReadableStream({ chunks: parts, chunkDelayInMs: null, initialDelayInMs: null }) };
|
||||
}
|
||||
|
||||
function makeRepo(): { dir: string; cleanup: () => void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'shiro-scaffold-'));
|
||||
const cleanup = () => rmSync(dir, { recursive: true, force: true });
|
||||
return { dir, cleanup };
|
||||
}
|
||||
|
||||
const ALL_FOUR = `===FILE TODO.md===
|
||||
# TODO
|
||||
|
||||
## Now
|
||||
- first
|
||||
|
||||
## Next
|
||||
- second
|
||||
|
||||
===FILE ROADMAP.md===
|
||||
# Roadmap
|
||||
|
||||
## Next
|
||||
- plan
|
||||
|
||||
===FILE docs/README.md===
|
||||
# Docs
|
||||
|
||||
Developer docs.
|
||||
|
||||
===FILE AGENTS.md===
|
||||
# AGENTS
|
||||
|
||||
This project is a test.
|
||||
`;
|
||||
|
||||
test('projectTracksAt is false for an empty repo and true when a tracker exists', async () => {
|
||||
const { dir, cleanup } = makeRepo();
|
||||
try {
|
||||
expect(projectTracksAt(dir)).toBe(false);
|
||||
await Bun.write(join(dir, 'TODO.md'), '# Todo\n');
|
||||
expect(projectTracksAt(dir)).toBe(true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scaffoldMissingAuto writes all four files when the repo has none', async () => {
|
||||
const { dir, cleanup } = makeRepo();
|
||||
try {
|
||||
const model = new MockLanguageModelV4({ doStream: async () => stream(ALL_FOUR) });
|
||||
const written = await scaffoldMissingAuto(dir, model);
|
||||
// Expect 4 files written.
|
||||
expect(written.length).toBe(4);
|
||||
expect(existsSync(join(dir, 'TODO.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'docs', 'README.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true);
|
||||
// Content came from the model, not the empty template.
|
||||
expect(readFileSync(join(dir, 'TODO.md'), 'utf8')).toContain('- first');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scaffoldMissingAuto bails when a repo already tracks progress', async () => {
|
||||
const { dir, cleanup } = makeRepo();
|
||||
try {
|
||||
await Bun.write(join(dir, 'TODO.md'), '# Todo\n- [ ] existing\n');
|
||||
const model = new MockLanguageModelV4({ doStream: async () => stream('') });
|
||||
const written = await scaffoldMissingAuto(dir, model);
|
||||
expect(written).toEqual([]);
|
||||
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(false);
|
||||
expect(readFileSync(join(dir, 'TODO.md'), 'utf8')).toBe('# Todo\n- [ ] existing\n');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scaffoldMissingAuto degrades to empty templates when the model fails', async () => {
|
||||
const { dir, cleanup } = makeRepo();
|
||||
try {
|
||||
const model = new MockLanguageModelV4({ doStream: async () => { throw new Error('model down'); } });
|
||||
const written = await scaffoldMissingAuto(dir, model);
|
||||
// The fallback writes the empty-tracker templates so the session never dies.
|
||||
expect(written.length).toBeGreaterThanOrEqual(3);
|
||||
expect(existsSync(join(dir, 'TODO.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scaffoldMissingAuto bails entirely when any tracking file exists', async () => {
|
||||
const { dir, cleanup } = makeRepo();
|
||||
try {
|
||||
await Bun.write(join(dir, 'TODO.md'), 'precious');
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async () =>
|
||||
stream('===FILE TODO.md===\n# TODO\noverwrite me\n===FILE ROADMAP.md===\n# Roadmap\n'),
|
||||
});
|
||||
const written = await scaffoldMissingAuto(dir, model);
|
||||
// A repo with any tracker is left alone — nothing new is written.
|
||||
expect(written).toEqual([]);
|
||||
expect(readFileSync(join(dir, 'TODO.md'), 'utf8')).toBe('precious');
|
||||
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
+58
-1
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Session } from '../src/session';
|
||||
import { interruptBash } from '../src/tools';
|
||||
import { backgroundHandles, interruptBash } from '../src/tools';
|
||||
|
||||
const usage = usageOf(10, 5);
|
||||
|
||||
@@ -373,3 +373,60 @@ test('an interrupted command becomes a tool error and the turn carries on', asyn
|
||||
expect(kinds.at(-1)).toBe('done');
|
||||
expect(call).toBe(2);
|
||||
}), 30_000);
|
||||
|
||||
test('a background command runs, streams via the listener, and can be stopped', async () =>
|
||||
inTempDir(async () => {
|
||||
const script = process.platform === 'win32' ? 'ping -n 3 127.0.0.1 > nul' : 'sleep 0.2; echo bg-line';
|
||||
let call = 0;
|
||||
let modelCalls = 0;
|
||||
const session = new Session({
|
||||
yolo: true,
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => {
|
||||
const n = call++;
|
||||
modelCalls += 1;
|
||||
if (n === 0) return stream(toolCall('c1', 'bash', { command: script, background: true, name: 'bgtest' }));
|
||||
// The model polls whatever handle the start actually produced — the
|
||||
// counter is module-global, so it is not necessarily 1 when other
|
||||
// test files ran in the same process first.
|
||||
const handles = backgroundHandles().sort((a, b) => a - b);
|
||||
const handle = handles.at(-1) ?? 1;
|
||||
if (n === 1) return stream(toolCall('c2', 'bash_status', { handle }));
|
||||
// Give the script time to finish, then poll once more and stop it.
|
||||
await Bun.sleep(1500);
|
||||
if (n === 2) return stream(toolCall('c3', 'bash_status', { handle }));
|
||||
if (n === 3) return stream(toolCall('c4', 'bash_stop', { handle }));
|
||||
return stream(text('all done'));
|
||||
},
|
||||
}),
|
||||
askApproval: async (req) => {
|
||||
// In this session-scoped test the only gated call is the bash background
|
||||
// start itself; approve it once so the model's own calls never see the prompt.
|
||||
return req.toolName === 'bash' ? 'once' : 'always';
|
||||
},
|
||||
});
|
||||
|
||||
const results: string[] = [];
|
||||
const streamed: string[] = [];
|
||||
for await (const ev of session.send('run the dev server in the background and check it')) {
|
||||
if (ev.type === 'tool-result') results.push(String(ev.output));
|
||||
if (ev.type === 'tool-output') streamed.push(String(ev.chunk));
|
||||
}
|
||||
|
||||
// The start returned a handle; the first poll saw running; the final poll saw it finish.
|
||||
// Don't hardcode the handle number — read it out of the first tool result,
|
||||
// since the counter is shared module-global state and the process may have
|
||||
// been stopped by the final bash_stop by the time these assertions run.
|
||||
const actualHandle = Number(results[0]?.match(/background (\d+):/)?.[1]);
|
||||
expect(actualHandle).toBeGreaterThan(0);
|
||||
expect(results[0]).toContain(`background ${actualHandle}:`);
|
||||
expect(results[1]).toContain('running');
|
||||
expect(results[2]).toContain('finished');
|
||||
expect(results[3]).toContain(`killed ${actualHandle}`);
|
||||
// Output streamed through the session's tool-output events (the live panel).
|
||||
expect(streamed.join('')).toContain('bg-line');
|
||||
expect(modelCalls).toBeGreaterThanOrEqual(4);
|
||||
// Nothing left running after the turn's tool calls.
|
||||
const { shutdownBackgrounds } = await import('../src/tools');
|
||||
await shutdownBackgrounds();
|
||||
}), 30_000);
|
||||
@@ -5,6 +5,8 @@ import { join } from 'node:path';
|
||||
import {
|
||||
applyPatchTool,
|
||||
bashTool,
|
||||
bashStatusTool,
|
||||
bashStopTool,
|
||||
deleteFileTool,
|
||||
editFileTool,
|
||||
globTool,
|
||||
@@ -22,6 +24,10 @@ import {
|
||||
tools,
|
||||
toolSetOf,
|
||||
writeFileTool,
|
||||
shutdownBackgrounds,
|
||||
startBackground,
|
||||
stopBackground,
|
||||
statusBackground,
|
||||
} from '../src/tools';
|
||||
|
||||
let dir: string;
|
||||
@@ -581,3 +587,94 @@ test('a command that finished is no longer interruptible', async () => {
|
||||
await run(bashTool, { command: 'echo done' });
|
||||
expect(interruptBash()).toEqual([]);
|
||||
}, 20_000);
|
||||
|
||||
// --- background commands (dev servers, watchers) ---
|
||||
|
||||
const bgScript = process.platform === 'win32' ? 'ping -n 3 127.0.0.1 > nul' : 'sleep 0.6; echo bg-done';
|
||||
|
||||
test('bash background returns immediately with a handle', async () => {
|
||||
const started = Date.now();
|
||||
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'sleeper' });
|
||||
// The counter is module-global, so the number depends on what ran before in
|
||||
// the same process — extract it rather than assuming it is 1.
|
||||
const handle = Number(out.match(/background (\d+):/)?.[1]);
|
||||
expect(handle).toBeGreaterThan(0);
|
||||
expect(out).toContain('running');
|
||||
expect(out).toContain('bash_status');
|
||||
// Returns before the 30s command finishes.
|
||||
expect(Date.now() - started).toBeLessThan(5_000);
|
||||
await shutdownBackgrounds();
|
||||
}, 20_000);
|
||||
|
||||
test('bash_status reports running then finished with the exit code', async () => {
|
||||
const out = await run(bashTool, { command: bgScript, background: true, name: 'bgtest' });
|
||||
const handle = Number(out.match(/background (\d+)/)?.[1]);
|
||||
expect(handle).toBeGreaterThan(0);
|
||||
|
||||
const early = statusBackground(handle);
|
||||
expect(early).toContain('running');
|
||||
|
||||
// Wait for the script to finish.
|
||||
let status = '';
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
await Bun.sleep(200);
|
||||
status = statusBackground(handle);
|
||||
if (status.includes('finished')) break;
|
||||
}
|
||||
expect(status).toContain('finished');
|
||||
expect(status).toContain('exit 0');
|
||||
await shutdownBackgrounds();
|
||||
}, 25_000);
|
||||
|
||||
test('bash_status unknown handle reports no such handle', async () => {
|
||||
expect(statusBackground(999)).toContain('no such handle');
|
||||
});
|
||||
|
||||
test('bash_stop kills a running background command', async () => {
|
||||
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'killer' });
|
||||
const handle = Number(out.match(/background (\d+)/)?.[1]);
|
||||
const started = Date.now();
|
||||
const msg = await run(bashStopTool, { handle });
|
||||
expect(msg).toContain(`killed ${handle}`);
|
||||
expect(msg).toContain('sleep 30');
|
||||
// Killed, not waited out.
|
||||
expect(Date.now() - started).toBeLessThan(10_000);
|
||||
await shutdownBackgrounds();
|
||||
}, 25_000);
|
||||
|
||||
test('bash_stop unknown handle is reported without throwing', async () => {
|
||||
const out = await run(bashStopTool, { handle: 4242 });
|
||||
expect(out).toContain('no such handle');
|
||||
});
|
||||
|
||||
test('bash_status streams output into its buffer for a later read', async () => {
|
||||
const out = await run(bashTool, { command: bgScript, background: true, name: 'stream' });
|
||||
const handle = Number(out.match(/background (\d+)/)?.[1]);
|
||||
|
||||
// Give the script time to print its line.
|
||||
await Bun.sleep(1200);
|
||||
const status = statusBackground(handle);
|
||||
expect(status.toLowerCase()).toContain('bg-done');
|
||||
await shutdownBackgrounds();
|
||||
}, 20_000);
|
||||
|
||||
test('background commands are not interrupted by interruptBash (foreground)', async () => {
|
||||
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'immune' });
|
||||
await Bun.sleep(200);
|
||||
// Foreground interrupt must not touch background commands.
|
||||
expect(interruptBash()).toEqual([]);
|
||||
const handle = Number(out.match(/background (\d+)/)?.[1]);
|
||||
expect(statusBackground(handle)).toContain('running');
|
||||
await shutdownBackgrounds();
|
||||
}, 20_000);
|
||||
|
||||
test('shutdownBackgrounds kills everything live and can be called twice', async () => {
|
||||
await run(bashTool, { command: 'sleep 30', background: true, name: 'cleanup' });
|
||||
const before = Date.now();
|
||||
await shutdownBackgrounds();
|
||||
// Killed, not waited out.
|
||||
expect(Date.now() - before).toBeLessThan(10_000);
|
||||
// Second call is a no-op.
|
||||
await shutdownBackgrounds();
|
||||
}, 20_000);
|
||||
+119
-16
@@ -2,7 +2,7 @@ import { usageOf } from './helpers';
|
||||
import { expect, test } from 'bun:test';
|
||||
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||
import type { LanguageModelV4StreamPart } from '@ai-sdk/provider';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Session } from '../src/session';
|
||||
@@ -85,7 +85,14 @@ test('workflow disabled renders no policy even with a TODO.md', () =>
|
||||
|
||||
test('bare repo renders no workflow policy', () =>
|
||||
inGitRepo(async () => {
|
||||
const session = new Session({ model: new MockLanguageModelV4({ doStream: async () => stream([]) }), askApproval: async () => "deny" });
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({ doStream: async () => stream([]) }),
|
||||
askApproval: async () => "deny",
|
||||
// Without this, the first send() would auto-scaffold TODO.md/etc into
|
||||
// the empty repo — that is the point of the feature, but this test is
|
||||
// specifically about the policy wire-up, so keep the repo bare.
|
||||
workflow: { autoScaffold: false },
|
||||
});
|
||||
const status = session.workflowStatus();
|
||||
expect(status.hasTodo).toBe(false);
|
||||
expect(status.hasRoadmap).toBe(false);
|
||||
@@ -184,13 +191,25 @@ test('nudge ladder: fires up to 3 times, then stops', async () => {
|
||||
await Bun.write(join(dir, 'TODO.md'), '# Todo\n- [ ] task\n');
|
||||
await Bun.write(join(dir, 'app.ts'), 'const a = 1;\n');
|
||||
|
||||
// Each SDK turn is one session.send(). The nudge fires once per turn that
|
||||
// wrote files without updating the task list — so the model must edit in
|
||||
// one turn, then end the turn with text (which lets the nudge fire), then
|
||||
// edit again next turn. Three nudges ⇒ three turns each with an edit and
|
||||
// a following text-only turn (the session re-invokes the model per turn,
|
||||
// so 'done' ends that turn and the loop stops).
|
||||
let call = 0;
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => {
|
||||
call++;
|
||||
if (call <= 3) return stream(toolCall(`c${call}`, 'edit_file', { path: 'app.ts', oldString: 'const a = 1;', newString: `const a = ${call + 1};` }));
|
||||
// Turn 4+: no tool call — agent stops
|
||||
// Turn boundaries: every send() calls doStream once. Odd calls (1,3,5)
|
||||
// edit; even calls (2,4,6) return text to end the turn. call>=7 → text
|
||||
// (agent finished after the 3rd nudge).
|
||||
if (call <= 6 && call % 2 === 1) {
|
||||
const content = (await Bun.file(join(dir, 'app.ts')).text()).trimEnd();
|
||||
const next = Math.ceil(call / 2) + 1;
|
||||
return stream(toolCall(`c${call}`, 'edit_file', { path: 'app.ts', oldString: content, newString: `const a = ${next};` }));
|
||||
}
|
||||
return stream(text('done'));
|
||||
},
|
||||
}),
|
||||
@@ -198,36 +217,54 @@ test('nudge ladder: fires up to 3 times, then stops', async () => {
|
||||
});
|
||||
|
||||
const notices: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for await (const ev of session.send(`turn ${i + 1}`)) {
|
||||
if (ev.type === 'notice') notices.push(ev.text);
|
||||
}
|
||||
}
|
||||
const nudgeNotices = notices.filter((n) => n.includes('without updating the project task list'));
|
||||
const nudgeNotices = notices.filter((n) => n.includes('TODO.md'));
|
||||
expect(nudgeNotices.length).toBe(3);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('nudge resets after todo_write in a later turn', async () => {
|
||||
test('todo_write in a turn suppresses that turn\'s nudge', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'shiro-wf-reset'));
|
||||
try {
|
||||
await Bun.write(join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
||||
await Bun.write(join(dir, 'TODO.md'), '# Todo\n- [ ] task\n');
|
||||
await Bun.write(join(dir, 'app.ts'), 'const a = 1;\n');
|
||||
|
||||
// One session.send() = one full agent run (the SDK loop re-invokes the
|
||||
// model until it returns text). The nudge fires once per send, and the
|
||||
// counter is a *lifetime* cap of 3 — todo_write only suppresses the nudge
|
||||
// for the turn in which it runs:
|
||||
// call 1 (edit+todo_write), call 2 text -> send 1 -> no nudge
|
||||
// call 3 (edit), call 4 text -> send 2 -> nudge 1
|
||||
// call 5 (edit), call 6 text -> send 3 -> nudge 2
|
||||
// call 7 (edit), call 8 text -> send 4 -> nudge 3
|
||||
// call 9 (edit), call 10 text -> send 5 -> capped, no nudge
|
||||
let call = 0;
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => {
|
||||
call++;
|
||||
// Turn 1-3: edit file → 3 nudges
|
||||
if (call <= 3) return stream(toolCall(`c${call}`, 'edit_file', { path: 'app.ts', oldString: 'const a = 1;', newString: `const a = ${call + 1};` }));
|
||||
// Turn 4: todo_write → resets the nudge counter
|
||||
if (call === 4) return stream(toolCall(`c${call}`, 'todo_write', { items: [{ text: 'completed task', done: true }] }));
|
||||
// Turn 5: edit file → should nudge again (counter was reset)
|
||||
return stream(toolCall(`c${call}`, 'edit_file', { path: 'app.ts', oldString: 'const a = 4;', newString: `const a = ${call + 1};` }));
|
||||
const c = await Bun.file(join(dir, 'app.ts')).text();
|
||||
const next = Math.floor((call + 1) / 2) + 1;
|
||||
if (call === 1) {
|
||||
// Same send: edit + todo_write -> nudge suppressed for this turn
|
||||
return stream([
|
||||
...toolCall('e1', 'edit_file', { path: 'app.ts', oldString: c.trimEnd(), newString: `const a = ${next};` }),
|
||||
...toolCall('w', 'todo_write', { items: [{ text: 'completed task', done: true }] }),
|
||||
]);
|
||||
}
|
||||
if (call % 2 === 1) {
|
||||
// Odd calls (3,5,7,9): edit -> each ends a send
|
||||
return stream(toolCall(`e${call}`, 'edit_file', { path: 'app.ts', oldString: c.trimEnd(), newString: `const a = ${next};` }));
|
||||
}
|
||||
// Even calls: text ends the send
|
||||
return stream(text('done'));
|
||||
},
|
||||
}),
|
||||
askApproval: async () => 'once',
|
||||
@@ -239,10 +276,76 @@ test('nudge resets after todo_write in a later turn', async () => {
|
||||
if (ev.type === 'notice') notices.push(ev.text);
|
||||
}
|
||||
}
|
||||
const nudgeNotices = notices.filter((n) => n.includes('without updating the project task list'));
|
||||
// 3 nudges (turns 1-3) + 1 reset + 1 more nudge (turn 5) = 4
|
||||
expect(nudgeNotices.length).toBe(4);
|
||||
const nudgeNotices = notices.filter((n) => n.includes('TODO.md'));
|
||||
// Send 1 suppressed (todo_write), sends 2-4 nudges 1-3, send 5 capped.
|
||||
expect(nudgeNotices.length).toBe(3);
|
||||
// The messages escalate (1st/2nd/3rd), proving the ladder.
|
||||
expect(nudgeNotices[0] ?? '').toContain('reminder:');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('first turn auto-scaffolds TODO.md/ROADMAP.md/docs/AGENTS.md in a bare repo', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'shiro-wf-autoscaffold'));
|
||||
const orig = process.cwd();
|
||||
process.chdir(dir);
|
||||
try {
|
||||
await Bun.write(join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
||||
|
||||
// The model returns the four workflow files on the scaffold call, then a
|
||||
// plain text answer for the actual turn.
|
||||
let call = 0;
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => {
|
||||
call++;
|
||||
if (call === 1) {
|
||||
// Scaffold call: emit text-delta chunks carrying all four file blocks.
|
||||
const body = [
|
||||
'===FILE TODO.md===',
|
||||
'# TODO\n\n## Now\n- first',
|
||||
'===FILE ROADMAP.md===',
|
||||
'# Roadmap\n\n## Next\n- plan',
|
||||
'===FILE docs/README.md===',
|
||||
'# Docs\n\nDeveloper docs.',
|
||||
'===FILE AGENTS.md===',
|
||||
'# AGENTS\n\nA test project.',
|
||||
].join('\n');
|
||||
return {
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: 'text-start', id: 's' },
|
||||
{ type: 'text-delta', id: 's', delta: body },
|
||||
{ type: 'text-end', id: 's' },
|
||||
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
|
||||
],
|
||||
chunkDelayInMs: null,
|
||||
initialDelayInMs: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// The actual turn: no tool calls, just text.
|
||||
return stream(text('ok'));
|
||||
},
|
||||
}),
|
||||
askApproval: async () => 'once',
|
||||
});
|
||||
|
||||
const notices: string[] = [];
|
||||
for await (const ev of session.send('hello')) {
|
||||
if (ev.type === 'notice') notices.push(ev.text);
|
||||
}
|
||||
|
||||
// The scaffold notice fired before the real turn.
|
||||
expect(notices.some((n) => n.startsWith('scaffolded project workflow files'))).toBe(true);
|
||||
// All four files now exist at the git root.
|
||||
expect(existsSync(join(dir, 'TODO.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'docs', 'README.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(orig);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user