@refleet-it/runner 0.1.186
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/backend-client.js +76 -0
- package/dist/backends/claude.js +142 -0
- package/dist/backends/kiro.js +224 -0
- package/dist/backends/types.js +2 -0
- package/dist/cli.js +165 -0
- package/dist/config.js +57 -0
- package/dist/detect.js +71 -0
- package/dist/fleet/api.js +117 -0
- package/dist/fleet/execute.js +33 -0
- package/dist/fleet/git.js +34 -0
- package/dist/fleet/gitlab.js +66 -0
- package/dist/fleet/job.js +108 -0
- package/dist/fleet/log.js +4 -0
- package/dist/fleet/loop.js +128 -0
- package/dist/fleet/workspace.js +124 -0
- package/dist/prompt.js +110 -0
- package/dist/run.js +77 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dawid Cichoń
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @refleet-it/runner
|
|
2
|
+
|
|
3
|
+
A runner is a worker that claims code-modernisation jobs from the [Refleet](https://refleet.it)
|
|
4
|
+
API, checks out the target GitLab repository, lets an AI coding agent do the work and opens the
|
|
5
|
+
result as a merge request. This package runs that loop on your own machine, with an agent you
|
|
6
|
+
already have installed.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Node.js 22 or newer
|
|
11
|
+
- `git` on `PATH`
|
|
12
|
+
- at least one supported agent CLI, authenticated:
|
|
13
|
+
- [Claude Code](https://docs.claude.com/en/docs/claude-code) (`claude`) — needs `ANTHROPIC_API_KEY`
|
|
14
|
+
- [Kiro CLI](https://kiro.dev/docs/cli/) (`kiro-cli`) — needs `KIRO_API_KEY`
|
|
15
|
+
|
|
16
|
+
The runner registers one Runner per agent it detects at startup, so a machine with both shows up
|
|
17
|
+
twice in Refleet.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @refleet-it/runner
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Connect and run
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
refleet-runner login # API URL, email, password → mints an API key and stores it locally
|
|
29
|
+
refleet-runner run
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`login` keeps its API key in `~/.config/refleet/runner.json` (`XDG_CONFIG_HOME` is honoured).
|
|
33
|
+
Anything headless — a server, a container, CI — should instead set `REFLEET_API_URL` and
|
|
34
|
+
`REFLEET_API_KEY` directly; when both are set they take precedence over the stored login.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
refleet-runner whoami # which credentials are in effect, and whether they still work
|
|
38
|
+
refleet-runner logout # revokes the stored key on the server and forgets it locally
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`run` has no idle mode: without credentials it exits immediately, and a job in progress is
|
|
42
|
+
finished before `SIGINT`/`SIGTERM` stop it. Keep it alive with whatever supervisor you already use;
|
|
43
|
+
a minimal systemd unit:
|
|
44
|
+
|
|
45
|
+
```ini
|
|
46
|
+
[Unit]
|
|
47
|
+
Description=Refleet runner
|
|
48
|
+
After=network-online.target
|
|
49
|
+
|
|
50
|
+
[Service]
|
|
51
|
+
ExecStart=/usr/bin/env refleet-runner run
|
|
52
|
+
Environment=REFLEET_API_URL=https://refleet.it/api
|
|
53
|
+
Environment=REFLEET_API_KEY=…
|
|
54
|
+
Environment=ANTHROPIC_API_KEY=…
|
|
55
|
+
Restart=always
|
|
56
|
+
RestartSec=10
|
|
57
|
+
|
|
58
|
+
[Install]
|
|
59
|
+
WantedBy=default.target
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
| Variable | Default | Meaning |
|
|
65
|
+
| --------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------- |
|
|
66
|
+
| `RUNNER_NAME` | the machine's hostname | Prefix for this runner's name in Refleet (`<name>-<engine>`) |
|
|
67
|
+
| `REFLEET_API_URL`, `REFLEET_API_KEY` | from `login` | Where and as whom to poll |
|
|
68
|
+
| `HEARTBEAT_INTERVAL_SECONDS` | `30` | How often it reports itself alive |
|
|
69
|
+
| `POLL_INTERVAL_SECONDS` | `10` | How often it asks for work |
|
|
70
|
+
| `WORKSPACE_CACHE_DIR` | `~/.cache/refleet/workspace` | Where cloned repositories are cached |
|
|
71
|
+
| `WORKSPACE_CACHE_MAX_SIZE_MB` | `5120` | Least-recently-used checkouts are evicted past this |
|
|
72
|
+
| `ANTHROPIC_API_KEY`, `KIRO_API_KEY` | — | Credentials for the agents this runner should offer |
|
|
73
|
+
| `CLAUDE_AVAILABLE_MODELS`, `KIRO_AVAILABLE_MODELS` | — | Comma-separated model ids offered in Refleet's model dropdown |
|
|
74
|
+
| `CLAUDE_MODEL`, `CLAUDE_QUALIFICATION_MODEL` | — | Override the model, generally and for qualification runs |
|
|
75
|
+
| `CLAUDE_ALLOWED_TOOLS`, `CLAUDE_QUALIFICATION_ALLOWED_TOOLS` | — | Restrict the tools the agent may use |
|
|
76
|
+
| `CLAUDE_PERMISSION_MODE`, `CLAUDE_EXTRA_ARGS`, `CLAUDE_ADD_DIRS` | — | Passed through to the Claude CLI |
|
|
77
|
+
| `CLAUDE_SKIP_PERMISSIONS` | `true` | Headless runs have nobody to approve prompts |
|
|
78
|
+
| `REFLEET_CLAUDE_PATH`, `REFLEET_KIRO_PATH` | — | Pin an explicit executable instead of looking it up on `PATH` |
|
|
79
|
+
|
|
80
|
+
GitLab access needs no configuration here: before each job the runner fetches the organisation's
|
|
81
|
+
GitLab URL and token from Refleet (set up once in Settings → Connect GitLab) and passes the token
|
|
82
|
+
per git invocation, never writing it into the cached checkout.
|
|
83
|
+
|
|
84
|
+
## Docker
|
|
85
|
+
|
|
86
|
+
The same loop ships as a container image with both agents pre-installed —
|
|
87
|
+
`registry.gitlab.com/slipway1/slipway/runner-agent`. See the
|
|
88
|
+
[runner installation guide](https://gitlab.com/slipway1/slipway/-/blob/main/docs/runner/installation.md).
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export class BackendRequestError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(message, status) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function baseUrl(apiUrl) {
|
|
9
|
+
return apiUrl.replace(/\/+$/, '');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* POST /identity/login — the same endpoint the frontend's own login form uses.
|
|
13
|
+
* Maps the two failure modes LoginController documents (see
|
|
14
|
+
* backend/.../Auth/Login/LoginController.php) to messages a CLI user acts on
|
|
15
|
+
* directly instead of a bare HTTP status.
|
|
16
|
+
*/
|
|
17
|
+
export async function login(apiUrl, email, password, fetchFn = fetch) {
|
|
18
|
+
const response = await fetchFn(`${baseUrl(apiUrl)}/identity/login`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({ email, password }),
|
|
22
|
+
});
|
|
23
|
+
if (429 === response.status) {
|
|
24
|
+
throw new BackendRequestError('too many failed login attempts — try again in a minute', response.status);
|
|
25
|
+
}
|
|
26
|
+
if (401 === response.status || 422 === response.status) {
|
|
27
|
+
throw new BackendRequestError('invalid email or password', response.status);
|
|
28
|
+
}
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new BackendRequestError(`login failed (status ${String(response.status)})`, response.status);
|
|
31
|
+
}
|
|
32
|
+
const body = (await response.json());
|
|
33
|
+
const jwtToken = body.token?.jwtToken;
|
|
34
|
+
if (!jwtToken) {
|
|
35
|
+
throw new BackendRequestError('login succeeded but the response had no JWT');
|
|
36
|
+
}
|
|
37
|
+
return { jwtToken };
|
|
38
|
+
}
|
|
39
|
+
/** POST /identity/api-keys, authenticated with the short-lived JWT from login(). */
|
|
40
|
+
export async function createApiKey(apiUrl, jwtToken, name, fetchFn = fetch) {
|
|
41
|
+
const response = await fetchFn(`${baseUrl(apiUrl)}/identity/api-keys`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwtToken}` },
|
|
44
|
+
body: JSON.stringify({ name }),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new BackendRequestError(`failed to create an API key (status ${String(response.status)})`, response.status);
|
|
48
|
+
}
|
|
49
|
+
return (await response.json());
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* DELETE /identity/api-keys/{id}. Accepts the API key itself as auth (Security
|
|
53
|
+
* resolves ApiKeyAuthenticator and JwtAuthenticator to the same AccountUser), so
|
|
54
|
+
* `logout` never needs to re-prompt for a password just to revoke its own key.
|
|
55
|
+
* A 404 (already revoked/deleted) is treated as success so this stays idempotent.
|
|
56
|
+
*/
|
|
57
|
+
export async function revokeApiKey(apiUrl, apiKey, apiKeyId, fetchFn = fetch) {
|
|
58
|
+
const response = await fetchFn(`${baseUrl(apiUrl)}/identity/api-keys/${apiKeyId}`, {
|
|
59
|
+
method: 'DELETE',
|
|
60
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok && 404 !== response.status) {
|
|
63
|
+
throw new BackendRequestError(`failed to revoke the API key (status ${String(response.status)})`, response.status);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** GET /identity/api-keys — used by `whoami` as a live validity/connectivity check. */
|
|
67
|
+
export async function listApiKeys(apiUrl, apiKey, fetchFn = fetch) {
|
|
68
|
+
const response = await fetchFn(`${baseUrl(apiUrl)}/identity/api-keys`, {
|
|
69
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
70
|
+
});
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new BackendRequestError(`failed to list API keys (status ${String(response.status)})`, response.status);
|
|
73
|
+
}
|
|
74
|
+
const body = (await response.json());
|
|
75
|
+
return body.apiKeys;
|
|
76
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { AgentExecutionError } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Builds the `claude` CLI argument list for one job kind. Ported 1:1 from
|
|
5
|
+
* runner/claude/entrypoint.sh's build_claude_args/build_kind_claude_args: qualification
|
|
6
|
+
* jobs are a read-only yes/no decision (must not modify the checkout) and default to
|
|
7
|
+
* read-only tools + an optional cheaper model, while change jobs get whatever
|
|
8
|
+
* model/tool access the operator configured for CLAUDE_MODEL/CLAUDE_ALLOWED_TOOLS.
|
|
9
|
+
*
|
|
10
|
+
* Unlike the bash version, this always adds --output-format stream-json so the output
|
|
11
|
+
* is structured NDJSON instead of the CLI's human-oriented text — see extractFinalText.
|
|
12
|
+
*/
|
|
13
|
+
export function buildClaudeArgs(kind, modelOverride, env) {
|
|
14
|
+
const args = [];
|
|
15
|
+
const permissionMode = env.CLAUDE_PERMISSION_MODE;
|
|
16
|
+
if (permissionMode) {
|
|
17
|
+
args.push('--permission-mode', permissionMode);
|
|
18
|
+
}
|
|
19
|
+
// Defaults to true: headless (`claude -p`, no TTY) has no one to interactively
|
|
20
|
+
// approve Edit/Write/Bash prompts, so without this the agent can only report that
|
|
21
|
+
// it lacks permission instead of acting.
|
|
22
|
+
const skipPermissions = env.CLAUDE_SKIP_PERMISSIONS ?? 'true';
|
|
23
|
+
if ('true' === skipPermissions) {
|
|
24
|
+
args.push('--dangerously-skip-permissions');
|
|
25
|
+
}
|
|
26
|
+
if (env.CLAUDE_ADD_DIRS) {
|
|
27
|
+
args.push('--add-dir', ...splitCsv(env.CLAUDE_ADD_DIRS));
|
|
28
|
+
}
|
|
29
|
+
if (env.CLAUDE_EXTRA_ARGS) {
|
|
30
|
+
args.push(...env.CLAUDE_EXTRA_ARGS.split(/\s+/).filter(Boolean));
|
|
31
|
+
}
|
|
32
|
+
const isQualification = 'qualification' === kind;
|
|
33
|
+
const model = modelOverride ||
|
|
34
|
+
(isQualification ? env.CLAUDE_QUALIFICATION_MODEL || env.CLAUDE_MODEL : env.CLAUDE_MODEL);
|
|
35
|
+
const allowedToolsCsv = isQualification
|
|
36
|
+
? env.CLAUDE_QUALIFICATION_ALLOWED_TOOLS || env.CLAUDE_ALLOWED_TOOLS || 'Read,Grep,Glob'
|
|
37
|
+
: env.CLAUDE_ALLOWED_TOOLS;
|
|
38
|
+
if (model) {
|
|
39
|
+
args.push('--model', model);
|
|
40
|
+
}
|
|
41
|
+
if (allowedToolsCsv) {
|
|
42
|
+
args.push('--allowedTools', ...splitCsv(allowedToolsCsv));
|
|
43
|
+
}
|
|
44
|
+
args.push('--output-format', 'stream-json', '--verbose');
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
function splitCsv(value) {
|
|
48
|
+
return value
|
|
49
|
+
.split(',')
|
|
50
|
+
.map(part => part.trim())
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Extracts the agent's final text from `claude --output-format stream-json` NDJSON
|
|
55
|
+
* output. The CLI's own closing `type: "result"` event carries the complete final
|
|
56
|
+
* answer in `.result`; if the process exits before emitting one (or on an older CLI
|
|
57
|
+
* that omits it), falls back to concatenating every streamed assistant text block.
|
|
58
|
+
*
|
|
59
|
+
* Non-JSON lines are skipped rather than failing the whole parse — stray log lines on
|
|
60
|
+
* stdout should not sink an otherwise-successful run.
|
|
61
|
+
*/
|
|
62
|
+
export function extractFinalText(lines) {
|
|
63
|
+
const assistantTextParts = [];
|
|
64
|
+
let resultText = null;
|
|
65
|
+
let isError = false;
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
const trimmed = line.trim();
|
|
68
|
+
if ('' === trimmed) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
let event;
|
|
72
|
+
try {
|
|
73
|
+
event = JSON.parse(trimmed);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if ('result' === event.type) {
|
|
79
|
+
resultText = event.result ?? resultText;
|
|
80
|
+
isError = event.is_error ?? isError;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if ('assistant' === event.type && 'assistant' === event.message?.role) {
|
|
84
|
+
for (const block of event.message.content ?? []) {
|
|
85
|
+
if ('text' === block.type && block.text) {
|
|
86
|
+
assistantTextParts.push(block.text);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const output = resultText ?? assistantTextParts.join('');
|
|
92
|
+
return { output, isError };
|
|
93
|
+
}
|
|
94
|
+
export class ClaudeBackend {
|
|
95
|
+
executablePath;
|
|
96
|
+
env;
|
|
97
|
+
spawnFn;
|
|
98
|
+
constructor(executablePath = 'claude', env = process.env, spawnFn = spawn) {
|
|
99
|
+
this.executablePath = executablePath;
|
|
100
|
+
this.env = env;
|
|
101
|
+
this.spawnFn = spawnFn;
|
|
102
|
+
}
|
|
103
|
+
async execute(prompt, opts) {
|
|
104
|
+
const args = ['-p', prompt, ...buildClaudeArgs(opts.kind, opts.model, this.env)];
|
|
105
|
+
const lines = await new Promise((resolve, reject) => {
|
|
106
|
+
const collected = [];
|
|
107
|
+
let buffer = '';
|
|
108
|
+
const child = this.spawnFn(this.executablePath, args, {
|
|
109
|
+
cwd: opts.cwd,
|
|
110
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
111
|
+
});
|
|
112
|
+
child.stdout.setEncoding('utf8');
|
|
113
|
+
child.stdout.on('data', (chunk) => {
|
|
114
|
+
buffer += chunk;
|
|
115
|
+
const parts = buffer.split('\n');
|
|
116
|
+
buffer = parts.pop() ?? '';
|
|
117
|
+
collected.push(...parts);
|
|
118
|
+
});
|
|
119
|
+
child.on('error', err => {
|
|
120
|
+
reject(new AgentExecutionError(`failed to start claude: ${err.message}`));
|
|
121
|
+
});
|
|
122
|
+
child.on('close', code => {
|
|
123
|
+
if ('' !== buffer) {
|
|
124
|
+
collected.push(buffer);
|
|
125
|
+
}
|
|
126
|
+
if (0 !== code) {
|
|
127
|
+
reject(new AgentExecutionError(`claude exited with status ${String(code)}`));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
resolve(collected);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
const { output, isError } = extractFinalText(lines);
|
|
134
|
+
if (isError) {
|
|
135
|
+
throw new AgentExecutionError(output || 'claude reported an error with no message');
|
|
136
|
+
}
|
|
137
|
+
if ('' === output.trim()) {
|
|
138
|
+
throw new AgentExecutionError('claude produced no output');
|
|
139
|
+
}
|
|
140
|
+
return { output };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { AgentExecutionError } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Pulls the ACP session id out of a `session/new` (or `session/load`) response.
|
|
5
|
+
* Pure/testable in isolation from the actual JSON-RPC transport.
|
|
6
|
+
*/
|
|
7
|
+
export function extractSessionId(result) {
|
|
8
|
+
if (!result || 'object' !== typeof result) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const value = result.sessionId;
|
|
12
|
+
return 'string' === typeof value && '' !== value ? value : null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Picks which option to reply with when kiro-cli asks for tool-use permission via a
|
|
16
|
+
* peer-initiated `session/request_permission` request — this runner is headless (no
|
|
17
|
+
* human present to click "allow"), so it always auto-approves, preferring
|
|
18
|
+
* "allow_always" over "allow_once" to avoid re-prompting for the rest of the turn.
|
|
19
|
+
* Falls back to the first offered option if neither is present (schema guarantees at
|
|
20
|
+
* least one), so a run never hangs waiting for a human that isn't there.
|
|
21
|
+
*/
|
|
22
|
+
export function selectAutoApproveOptionId(options) {
|
|
23
|
+
const allowAlways = options.find(option => 'allow_always' === option.kind);
|
|
24
|
+
if (allowAlways) {
|
|
25
|
+
return allowAlways.optionId;
|
|
26
|
+
}
|
|
27
|
+
const allowOnce = options.find(option => 'allow_once' === option.kind);
|
|
28
|
+
if (allowOnce) {
|
|
29
|
+
return allowOnce.optionId;
|
|
30
|
+
}
|
|
31
|
+
return options[0]?.optionId ?? null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Pulls streamed text out of a `session/update` notification. ACP's sessionUpdate
|
|
35
|
+
* carries several variants (agent_message_chunk, agent_thought_chunk, tool_call, ...)
|
|
36
|
+
* — only agent_message_chunk text blocks contribute to the final answer; everything
|
|
37
|
+
* else (tool calls, thinking) is narration we don't need for a one-shot RunnerJob.
|
|
38
|
+
*
|
|
39
|
+
* Best-effort against the publicly documented ACP notification shape — verify against
|
|
40
|
+
* the installed kiro-cli version if this ever comes back empty for a run that clearly
|
|
41
|
+
* produced output.
|
|
42
|
+
*/
|
|
43
|
+
export function extractUpdateText(params) {
|
|
44
|
+
if (!params || 'object' !== typeof params) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const update = params.update;
|
|
48
|
+
if (!update || 'object' !== typeof update) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const { sessionUpdate, content } = update;
|
|
52
|
+
if ('agent_message_chunk' !== sessionUpdate || !content || 'object' !== typeof content) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const { type, text } = content;
|
|
56
|
+
return 'text' === type && 'string' === typeof text ? text : null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Minimal ACP JSON-RPC 2.0 client over a child process's stdio: newline-delimited
|
|
60
|
+
* JSON in both directions. Three kinds of incoming message: a response to one of our
|
|
61
|
+
* requests (`id` + `result`/`error`), a peer-initiated request we must answer (`id` +
|
|
62
|
+
* `method` — kiro-cli asking something of us, e.g. session/request_permission), or a
|
|
63
|
+
* notification (`method`, no `id`). Mirrors the shape of multica's hermesClient
|
|
64
|
+
* (server/pkg/agent/hermes.go), scoped down to what a single request/session/prompt
|
|
65
|
+
* turn needs.
|
|
66
|
+
*/
|
|
67
|
+
class AcpConnection {
|
|
68
|
+
child;
|
|
69
|
+
onNotification;
|
|
70
|
+
nextId = 1;
|
|
71
|
+
pending = new Map();
|
|
72
|
+
buffer = '';
|
|
73
|
+
constructor(child, onNotification) {
|
|
74
|
+
this.child = child;
|
|
75
|
+
this.onNotification = onNotification;
|
|
76
|
+
child.stdout.setEncoding('utf8');
|
|
77
|
+
child.stdout.on('data', (chunk) => this.handleData(chunk));
|
|
78
|
+
child.on('close', () => this.rejectAllPending(new AgentExecutionError('kiro-cli process exited')));
|
|
79
|
+
child.on('error', err => this.rejectAllPending(new AgentExecutionError(`kiro-cli process error: ${err.message}`)));
|
|
80
|
+
}
|
|
81
|
+
request(method, params) {
|
|
82
|
+
const id = this.nextId++;
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
this.pending.set(id, { resolve, reject });
|
|
85
|
+
this.send({ jsonrpc: '2.0', id, method, params }, err => {
|
|
86
|
+
if (err) {
|
|
87
|
+
this.pending.delete(id);
|
|
88
|
+
reject(new AgentExecutionError(`failed to write to kiro-cli: ${err.message}`));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
send(message, cb) {
|
|
94
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`, cb);
|
|
95
|
+
}
|
|
96
|
+
handleData(chunk) {
|
|
97
|
+
this.buffer += chunk;
|
|
98
|
+
const lines = this.buffer.split('\n');
|
|
99
|
+
this.buffer = lines.pop() ?? '';
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
this.handleLine(line);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
handleLine(line) {
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
if ('' === trimmed) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
let message;
|
|
110
|
+
try {
|
|
111
|
+
message = JSON.parse(trimmed);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const hasId = 'number' === typeof message.id;
|
|
117
|
+
const hasMethod = 'string' === typeof message.method;
|
|
118
|
+
if (hasId && hasMethod) {
|
|
119
|
+
this.handlePeerRequest(message.id, message.method, message.params);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (hasId) {
|
|
123
|
+
const pending = this.pending.get(message.id);
|
|
124
|
+
if (!pending) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
this.pending.delete(message.id);
|
|
128
|
+
if (message.error) {
|
|
129
|
+
pending.reject(new AgentExecutionError(`kiro-cli error ${message.error.code}: ${message.error.message}`));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
pending.resolve(message.result);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (hasMethod) {
|
|
137
|
+
this.onNotification(message.method, message.params);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Answers a request kiro-cli sent to us. This runner runs headless with no human to
|
|
142
|
+
* approve anything, so the only peer request it understands — session/request_permission
|
|
143
|
+
* — is always auto-approved (see selectAutoApproveOptionId). Any other peer request
|
|
144
|
+
* gets a JSON-RPC "method not found" error rather than being left unanswered, so
|
|
145
|
+
* kiro-cli never blocks forever waiting for a reply we were never going to send.
|
|
146
|
+
*/
|
|
147
|
+
handlePeerRequest(id, method, params) {
|
|
148
|
+
if ('session/request_permission' === method) {
|
|
149
|
+
const options = params?.options ?? [];
|
|
150
|
+
const optionId = selectAutoApproveOptionId(options);
|
|
151
|
+
const outcome = optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
|
|
152
|
+
this.send({ jsonrpc: '2.0', id, result: { outcome } });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.send({ jsonrpc: '2.0', id, error: { code: -32601, message: `unhandled peer request: ${method}` } });
|
|
156
|
+
}
|
|
157
|
+
rejectAllPending(error) {
|
|
158
|
+
for (const pending of this.pending.values()) {
|
|
159
|
+
pending.reject(error);
|
|
160
|
+
}
|
|
161
|
+
this.pending.clear();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export class KiroBackend {
|
|
165
|
+
executablePath;
|
|
166
|
+
env;
|
|
167
|
+
spawnFn;
|
|
168
|
+
constructor(executablePath = 'kiro-cli', env = process.env, spawnFn = spawn) {
|
|
169
|
+
this.executablePath = executablePath;
|
|
170
|
+
this.env = env;
|
|
171
|
+
this.spawnFn = spawnFn;
|
|
172
|
+
}
|
|
173
|
+
async execute(prompt, opts) {
|
|
174
|
+
// `--trust-all-tools` is documented for kiro-cli's standalone --no-interactive
|
|
175
|
+
// headless mode, not for `acp` — passing an unrecognized flag risks a hard
|
|
176
|
+
// startup failure there. Auto-approval in ACP mode instead goes through the
|
|
177
|
+
// protocol-native session/request_permission (see AcpConnection.handlePeerRequest).
|
|
178
|
+
const child = this.spawnFn(this.executablePath, ['acp'], {
|
|
179
|
+
cwd: opts.cwd,
|
|
180
|
+
env: this.env,
|
|
181
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
182
|
+
});
|
|
183
|
+
const chunks = [];
|
|
184
|
+
const connection = new AcpConnection(child, (method, params) => {
|
|
185
|
+
if ('session/update' === method) {
|
|
186
|
+
const text = extractUpdateText(params);
|
|
187
|
+
if (text) {
|
|
188
|
+
chunks.push(text);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
await connection.request('initialize', {
|
|
194
|
+
protocolVersion: 1,
|
|
195
|
+
clientInfo: { name: 'slipway-agent-runner', version: '0.1.0' },
|
|
196
|
+
clientCapabilities: {},
|
|
197
|
+
});
|
|
198
|
+
const sessionResult = await connection.request('session/new', {
|
|
199
|
+
cwd: opts.cwd,
|
|
200
|
+
mcpServers: [],
|
|
201
|
+
});
|
|
202
|
+
const sessionId = extractSessionId(sessionResult);
|
|
203
|
+
if (null === sessionId) {
|
|
204
|
+
throw new AgentExecutionError('kiro-cli session/new returned no session ID');
|
|
205
|
+
}
|
|
206
|
+
if (opts.model) {
|
|
207
|
+
await connection.request('session/set_model', { sessionId, modelId: opts.model });
|
|
208
|
+
}
|
|
209
|
+
await connection.request('session/prompt', {
|
|
210
|
+
sessionId,
|
|
211
|
+
prompt: [{ type: 'text', text: prompt }],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
child.stdin.end();
|
|
216
|
+
child.kill();
|
|
217
|
+
}
|
|
218
|
+
const output = chunks.join('').trim();
|
|
219
|
+
if ('' === output) {
|
|
220
|
+
throw new AgentExecutionError('kiro-cli produced no output');
|
|
221
|
+
}
|
|
222
|
+
return { output };
|
|
223
|
+
}
|
|
224
|
+
}
|