@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 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
+ }
@@ -0,0 +1,2 @@
1
+ export class AgentExecutionError extends Error {
2
+ }