@losclaws/cli 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/package.json +5 -2
- package/src/acp-runtime.js +1083 -0
- package/src/cli.js +179 -3
- package/src/config.js +25 -2
- package/src/workshop-local.js +785 -0
- package/test/acp-runtime.test.js +231 -0
- package/test/config.test.js +6 -1
- package/test/workshop-local.test.js +83 -0
- package/testdata/mock-acp-agent.js +135 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { Readable, Writable } from 'node:stream';
|
|
6
|
+
import readline from 'node:readline/promises';
|
|
7
|
+
|
|
8
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
9
|
+
|
|
10
|
+
import { CliError } from './errors.js';
|
|
11
|
+
import { loadWorkshopState } from './workshop-local.js';
|
|
12
|
+
|
|
13
|
+
const defaultOutputByteLimit = 1024 * 1024;
|
|
14
|
+
const readOnlyToolKinds = new Set(['read', 'search', 'fetch', 'think', 'other']);
|
|
15
|
+
const defaultFeedbackTimeoutMs = 30 * 60 * 1000;
|
|
16
|
+
const defaultFeedbackPollIntervalMs = 3000;
|
|
17
|
+
|
|
18
|
+
export async function runWorkshopAcpTask({ requestWorkshop, state, options }) {
|
|
19
|
+
const taskId = String(options.id || options.taskId || '').trim();
|
|
20
|
+
if (!taskId) {
|
|
21
|
+
throw new CliError('--id is required.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rootPath = path.resolve(options.root || process.cwd());
|
|
25
|
+
const localState = await loadWorkshopState(rootPath);
|
|
26
|
+
const detail = await requestWorkshop({
|
|
27
|
+
path: `/api/v1/tasks/${taskId}`,
|
|
28
|
+
});
|
|
29
|
+
// GET /tasks/{id} returns a task detail envelope ({ task, projectId, ... }).
|
|
30
|
+
// Tolerate a flat task object too (used by unit tests and older shapes).
|
|
31
|
+
const task = detail && typeof detail === 'object' && detail.task ? detail.task : detail;
|
|
32
|
+
const role = String(options.role || extractTaskRole(task) || localState.role || 'worker').trim() || 'worker';
|
|
33
|
+
const resolvedRuntime = resolveAcpRuntime({
|
|
34
|
+
task,
|
|
35
|
+
options,
|
|
36
|
+
});
|
|
37
|
+
const context = buildTaskPrompt({
|
|
38
|
+
task,
|
|
39
|
+
localState,
|
|
40
|
+
role,
|
|
41
|
+
extraMessage: options.message || '',
|
|
42
|
+
defaultPrompt: resolvedRuntime.runtime.defaultPrompt || '',
|
|
43
|
+
model: resolvedRuntime.runtime.model || '',
|
|
44
|
+
});
|
|
45
|
+
const taskVersion = asNumberOrNull(task?.version);
|
|
46
|
+
|
|
47
|
+
if (options.dryRun) {
|
|
48
|
+
return {
|
|
49
|
+
dryRun: true,
|
|
50
|
+
taskId,
|
|
51
|
+
role,
|
|
52
|
+
rootPath,
|
|
53
|
+
agent: {
|
|
54
|
+
name: resolvedRuntime.name,
|
|
55
|
+
command: resolvedRuntime.runtime.command,
|
|
56
|
+
args: resolvedRuntime.runtime.args,
|
|
57
|
+
model: resolvedRuntime.runtime.model || null,
|
|
58
|
+
},
|
|
59
|
+
resolvedFrom: resolvedRuntime.source,
|
|
60
|
+
taskVersion,
|
|
61
|
+
prompt: context.prompt,
|
|
62
|
+
artifactPaths: context.artifactPaths,
|
|
63
|
+
skillPaths: context.skillPaths,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const runDirectory = path.join(rootPath, '.losclaws', 'acp-runs');
|
|
68
|
+
await fs.mkdir(runDirectory, { recursive: true });
|
|
69
|
+
const runId = `${taskId}-${Date.now()}`;
|
|
70
|
+
const logPath = path.join(runDirectory, `${runId}.ndjson`);
|
|
71
|
+
|
|
72
|
+
const child = spawn(resolvedRuntime.runtime.command, resolvedRuntime.runtime.args, {
|
|
73
|
+
cwd: resolvedRuntime.runtime.cwd || rootPath,
|
|
74
|
+
env: {
|
|
75
|
+
...process.env,
|
|
76
|
+
...resolvedRuntime.runtime.env,
|
|
77
|
+
},
|
|
78
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!child.stdin || !child.stdout || !child.stderr) {
|
|
82
|
+
throw new CliError(`Failed to launch coder "${resolvedRuntime.name}".`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const bridge = new WorkshopTaskBridge({
|
|
86
|
+
requestWorkshop,
|
|
87
|
+
taskId,
|
|
88
|
+
version: taskVersion,
|
|
89
|
+
approvalPolicy: String(options.approvalPolicy || 'interactive'),
|
|
90
|
+
feedbackTimeoutMs: asNumberOrNull(options.feedbackTimeout) ?? defaultFeedbackTimeoutMs,
|
|
91
|
+
});
|
|
92
|
+
const client = new LosClawsAcpClient({
|
|
93
|
+
approvalPolicy: String(options.approvalPolicy || 'interactive'),
|
|
94
|
+
logPath,
|
|
95
|
+
bridge,
|
|
96
|
+
json: Boolean(options.json),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
child.stderr.on('data', (chunk) => {
|
|
100
|
+
const message = chunk.toString('utf8');
|
|
101
|
+
if (message) {
|
|
102
|
+
process.stderr.write(message);
|
|
103
|
+
client.recordAgentStderr(message);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const stream = acp.ndJsonStream(
|
|
108
|
+
Writable.toWeb(child.stdin),
|
|
109
|
+
Readable.toWeb(child.stdout),
|
|
110
|
+
);
|
|
111
|
+
const connection = new acp.ClientSideConnection(() => client, stream);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const initializeResult = await connection.initialize({
|
|
115
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
116
|
+
clientCapabilities: {
|
|
117
|
+
fs: {
|
|
118
|
+
readTextFile: true,
|
|
119
|
+
writeTextFile: true,
|
|
120
|
+
},
|
|
121
|
+
terminal: true,
|
|
122
|
+
elicitation: {
|
|
123
|
+
form: {},
|
|
124
|
+
url: {},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
clientInfo: {
|
|
128
|
+
name: 'losclaws-cli',
|
|
129
|
+
title: 'LosClaws CLI',
|
|
130
|
+
version: '0.1.2',
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const authMethodId = selectAuthMethodId({
|
|
135
|
+
initializeResult,
|
|
136
|
+
explicitAuthMethodId: options.authMethodId,
|
|
137
|
+
runtimeAuthMethodId: resolvedRuntime.runtime.authMethodId,
|
|
138
|
+
});
|
|
139
|
+
if (authMethodId) {
|
|
140
|
+
await connection.authenticate({ methodId: authMethodId });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const session = await openAcpSession({
|
|
144
|
+
connection,
|
|
145
|
+
initializeResult,
|
|
146
|
+
client,
|
|
147
|
+
task,
|
|
148
|
+
rootPath,
|
|
149
|
+
options,
|
|
150
|
+
runtime: resolvedRuntime.runtime,
|
|
151
|
+
});
|
|
152
|
+
const promptResult = await connection.prompt({
|
|
153
|
+
sessionId: session.sessionId,
|
|
154
|
+
prompt: [
|
|
155
|
+
{
|
|
156
|
+
type: 'text',
|
|
157
|
+
text: context.prompt,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (options.closeSession && supportsSessionClose(initializeResult)) {
|
|
163
|
+
await connection.closeSession({ sessionId: session.sessionId });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await bridge.flush();
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
taskId,
|
|
170
|
+
role,
|
|
171
|
+
rootPath,
|
|
172
|
+
logPath,
|
|
173
|
+
resolvedFrom: resolvedRuntime.source,
|
|
174
|
+
agent: {
|
|
175
|
+
name: resolvedRuntime.name,
|
|
176
|
+
command: resolvedRuntime.runtime.command,
|
|
177
|
+
args: resolvedRuntime.runtime.args,
|
|
178
|
+
model: resolvedRuntime.runtime.model || null,
|
|
179
|
+
},
|
|
180
|
+
acp: {
|
|
181
|
+
protocolVersion: initializeResult.protocolVersion,
|
|
182
|
+
sessionId: session.sessionId,
|
|
183
|
+
stopReason: promptResult.stopReason,
|
|
184
|
+
agentInfo: initializeResult.agentInfo || null,
|
|
185
|
+
},
|
|
186
|
+
task: {
|
|
187
|
+
id: taskId,
|
|
188
|
+
version: bridge.version,
|
|
189
|
+
},
|
|
190
|
+
artifactPaths: context.artifactPaths,
|
|
191
|
+
skillPaths: context.skillPaths,
|
|
192
|
+
};
|
|
193
|
+
} finally {
|
|
194
|
+
await stopChildProcess(child);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function buildTaskPrompt({ task, localState, role, extraMessage = '', defaultPrompt = '', model = '' }) {
|
|
199
|
+
const rootPath = localState.rootPath;
|
|
200
|
+
const taskTitle = String(task?.title || task?.name || task?.summary || task?.id || 'Workshop task');
|
|
201
|
+
const taskBody = firstString(
|
|
202
|
+
task?.instructions,
|
|
203
|
+
task?.prompt,
|
|
204
|
+
task?.description,
|
|
205
|
+
task?.goal,
|
|
206
|
+
task?.details,
|
|
207
|
+
);
|
|
208
|
+
const artifactPaths = selectTaskArtifactPaths(task, localState).map((relativePath) => path.join(rootPath, relativePath));
|
|
209
|
+
const skillPaths = localState.skills
|
|
210
|
+
.filter((entry) => entry.role === role)
|
|
211
|
+
.map((entry) => path.join(rootPath, entry.path));
|
|
212
|
+
|
|
213
|
+
const lines = [
|
|
214
|
+
`You are executing a ClawWorkshop task through ACP.`,
|
|
215
|
+
`Task ID: ${String(task?.id || '')}`,
|
|
216
|
+
`Role: ${role}`,
|
|
217
|
+
`Workspace root: ${rootPath}`,
|
|
218
|
+
`Task title: ${taskTitle}`,
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
if (taskBody) {
|
|
222
|
+
lines.push('', 'Task instructions:', taskBody);
|
|
223
|
+
}
|
|
224
|
+
if (artifactPaths.length > 0) {
|
|
225
|
+
lines.push('', 'Use only these artifact paths unless the user explicitly expands scope:');
|
|
226
|
+
lines.push(...artifactPaths.map((entry) => `- ${entry}`));
|
|
227
|
+
}
|
|
228
|
+
if (skillPaths.length > 0) {
|
|
229
|
+
lines.push('', 'Relevant skill directories for this role:');
|
|
230
|
+
lines.push(...skillPaths.map((entry) => `- ${entry}`));
|
|
231
|
+
}
|
|
232
|
+
if (defaultPrompt) {
|
|
233
|
+
lines.push('', 'Agent runtime guidance:', defaultPrompt);
|
|
234
|
+
}
|
|
235
|
+
if (model) {
|
|
236
|
+
lines.push('', `Preferred model: ${model}`);
|
|
237
|
+
}
|
|
238
|
+
if (extraMessage) {
|
|
239
|
+
lines.push('', 'Additional operator instructions:', extraMessage);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
prompt: lines.join('\n'),
|
|
244
|
+
artifactPaths,
|
|
245
|
+
skillPaths,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// resolveAcpRuntime builds the coder launch spec. The server composes this in the
|
|
250
|
+
// task payload (task.acp) from the project role configuration, the coder catalog,
|
|
251
|
+
// and the assignee's uploaded inventory. CLI flags (--command/--args/--env/--model)
|
|
252
|
+
// override individual fields for local debugging only.
|
|
253
|
+
export function resolveAcpRuntime({ task, options = {} }) {
|
|
254
|
+
const descriptor = resolveServerAcpDescriptor(task);
|
|
255
|
+
const overrides = {
|
|
256
|
+
command: options.command,
|
|
257
|
+
args: options.args,
|
|
258
|
+
env: options.env,
|
|
259
|
+
model: options.model,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const command = String(overrides.command || descriptor?.command || '').trim();
|
|
263
|
+
if (!command) {
|
|
264
|
+
throw new CliError(
|
|
265
|
+
'The server returned no coder runtime for this task (task.acp is empty). ' +
|
|
266
|
+
'Run `losclaws workshop inspect` on the assignee and configure the project role ' +
|
|
267
|
+
'(coder + model) before running this task.',
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const runtime = {
|
|
272
|
+
name: String(descriptor?.key || descriptor?.name || command),
|
|
273
|
+
title: String(descriptor?.title || ''),
|
|
274
|
+
command,
|
|
275
|
+
args: Array.isArray(overrides.args)
|
|
276
|
+
? overrides.args.map((entry) => String(entry))
|
|
277
|
+
: Array.isArray(descriptor?.args)
|
|
278
|
+
? descriptor.args.map((entry) => String(entry))
|
|
279
|
+
: [],
|
|
280
|
+
env: { ...normalizeEnvMap(descriptor?.env), ...normalizeEnvMap(overrides.env) },
|
|
281
|
+
cwd: String(descriptor?.cwd || ''),
|
|
282
|
+
authMethodId: String(descriptor?.authMethodId || ''),
|
|
283
|
+
mcpServers: Array.isArray(descriptor?.mcpServers) ? descriptor.mcpServers : [],
|
|
284
|
+
defaultPrompt: String(descriptor?.defaultPrompt || ''),
|
|
285
|
+
model: String(overrides.model || descriptor?.model || ''),
|
|
286
|
+
protocol: String(descriptor?.protocol || 'acp'),
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
name: runtime.name,
|
|
291
|
+
runtime,
|
|
292
|
+
source: overrides.command ? 'cli-override' : 'server',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// resolveServerAcpDescriptor reads the server-composed coder launch spec from the
|
|
297
|
+
// task payload. `task.acp` is the canonical field; a few legacy aliases are
|
|
298
|
+
// tolerated for forward/backward compatibility.
|
|
299
|
+
export function resolveServerAcpDescriptor(task) {
|
|
300
|
+
if (!task || typeof task !== 'object') {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
const candidate = task.acp || task.acpConfig || task.coder || null;
|
|
304
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
return candidate;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function normalizeEnvMap(value) {
|
|
311
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
312
|
+
return {};
|
|
313
|
+
}
|
|
314
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, String(entry)]));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function extractTaskRole(task) {
|
|
318
|
+
return String(task?.role || task?.assigneeRole || task?.assignee_role || task?.participantRole || '').trim();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function selectTaskArtifactPaths(task, localState) {
|
|
322
|
+
const explicitIds = [
|
|
323
|
+
...(Array.isArray(task?.inputArtifacts) ? task.inputArtifacts : []),
|
|
324
|
+
...(Array.isArray(task?.input_artifacts) ? task.input_artifacts : []),
|
|
325
|
+
...(Array.isArray(task?.artifacts) ? task.artifacts : []),
|
|
326
|
+
]
|
|
327
|
+
.map((entry) => String(entry?.id || entry?.artifactId || entry || '').trim())
|
|
328
|
+
.filter(Boolean);
|
|
329
|
+
|
|
330
|
+
const ids = new Set(explicitIds);
|
|
331
|
+
const candidates = ids.size > 0
|
|
332
|
+
? localState.artifacts.filter((entry) => ids.has(entry.id))
|
|
333
|
+
: localState.artifacts;
|
|
334
|
+
return candidates.map((entry) => entry.relativePath).slice(0, 64);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function selectAuthMethodId({ initializeResult, explicitAuthMethodId, runtimeAuthMethodId }) {
|
|
338
|
+
const methods = Array.isArray(initializeResult.authMethods) ? initializeResult.authMethods : [];
|
|
339
|
+
if (explicitAuthMethodId) {
|
|
340
|
+
return explicitAuthMethodId;
|
|
341
|
+
}
|
|
342
|
+
if (runtimeAuthMethodId) {
|
|
343
|
+
return runtimeAuthMethodId;
|
|
344
|
+
}
|
|
345
|
+
if (methods.length === 1 && methods[0]?.id) {
|
|
346
|
+
return methods[0].id;
|
|
347
|
+
}
|
|
348
|
+
return '';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function openAcpSession({ connection, initializeResult, client, task, rootPath, options, runtime }) {
|
|
352
|
+
const mcpServers = Array.isArray(runtime.mcpServers) ? runtime.mcpServers : [];
|
|
353
|
+
const additionalDirectories = [];
|
|
354
|
+
|
|
355
|
+
if (options.acpSessionId) {
|
|
356
|
+
if (supportsSessionResume(initializeResult)) {
|
|
357
|
+
const result = await connection.resumeSession({
|
|
358
|
+
sessionId: options.acpSessionId,
|
|
359
|
+
cwd: rootPath,
|
|
360
|
+
additionalDirectories,
|
|
361
|
+
mcpServers,
|
|
362
|
+
});
|
|
363
|
+
client.setSessionRoots(options.acpSessionId, [rootPath, ...additionalDirectories]);
|
|
364
|
+
return {
|
|
365
|
+
sessionId: options.acpSessionId,
|
|
366
|
+
details: result,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (initializeResult.agentCapabilities?.loadSession) {
|
|
370
|
+
await connection.loadSession({
|
|
371
|
+
sessionId: options.acpSessionId,
|
|
372
|
+
cwd: rootPath,
|
|
373
|
+
additionalDirectories,
|
|
374
|
+
mcpServers,
|
|
375
|
+
});
|
|
376
|
+
client.setSessionRoots(options.acpSessionId, [rootPath, ...additionalDirectories]);
|
|
377
|
+
return {
|
|
378
|
+
sessionId: options.acpSessionId,
|
|
379
|
+
details: {},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
throw new CliError('This ACP agent does not support loading or resuming existing sessions.');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const result = await connection.newSession({
|
|
386
|
+
cwd: rootPath,
|
|
387
|
+
additionalDirectories,
|
|
388
|
+
mcpServers,
|
|
389
|
+
});
|
|
390
|
+
client.setSessionRoots(result.sessionId, [rootPath, ...additionalDirectories]);
|
|
391
|
+
await client.recordLifecycle('session_opened', {
|
|
392
|
+
taskId: String(task?.id || ''),
|
|
393
|
+
sessionId: result.sessionId,
|
|
394
|
+
});
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function supportsSessionResume(initializeResult) {
|
|
399
|
+
return Boolean(initializeResult.agentCapabilities?.sessionCapabilities?.resume);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function supportsSessionClose(initializeResult) {
|
|
403
|
+
return Boolean(initializeResult.agentCapabilities?.sessionCapabilities?.close);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function asNumberOrNull(value) {
|
|
407
|
+
if (value === null || value === undefined || value === '') {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
const number = Number(value);
|
|
411
|
+
return Number.isFinite(number) ? number : null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function firstString(...values) {
|
|
415
|
+
return values
|
|
416
|
+
.map((entry) => String(entry || '').trim())
|
|
417
|
+
.find(Boolean) || '';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
class LosClawsAcpClient {
|
|
421
|
+
constructor({ approvalPolicy, logPath, bridge, json }) {
|
|
422
|
+
this.approvalPolicy = approvalPolicy;
|
|
423
|
+
this.logPath = logPath;
|
|
424
|
+
this.bridge = bridge;
|
|
425
|
+
this.json = json;
|
|
426
|
+
this.sessionRoots = new Map();
|
|
427
|
+
this.terminals = new Map();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
setSessionRoots(sessionId, roots) {
|
|
431
|
+
this.sessionRoots.set(sessionId, roots.map((entry) => path.resolve(entry)));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async requestPermission(params) {
|
|
435
|
+
const outcome = await selectPermissionOutcome({
|
|
436
|
+
policy: this.approvalPolicy,
|
|
437
|
+
params,
|
|
438
|
+
bridge: this.bridge,
|
|
439
|
+
});
|
|
440
|
+
await this.recordLifecycle('permission', {
|
|
441
|
+
toolCallId: params.toolCall?.toolCallId,
|
|
442
|
+
outcome,
|
|
443
|
+
});
|
|
444
|
+
return {
|
|
445
|
+
outcome,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async sessionUpdate(params) {
|
|
450
|
+
await this.recordLifecycle('session_update', params);
|
|
451
|
+
await this.bridge.publishSessionUpdate(params);
|
|
452
|
+
printSessionUpdate(params, this.json);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async writeTextFile(params) {
|
|
456
|
+
const filePath = assertAllowedPath(params.sessionId, params.path, this.sessionRoots);
|
|
457
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
458
|
+
await fs.writeFile(filePath, params.content, 'utf8');
|
|
459
|
+
await this.recordLifecycle('write_text_file', params);
|
|
460
|
+
return {};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async readTextFile(params) {
|
|
464
|
+
const filePath = assertAllowedPath(params.sessionId, params.path, this.sessionRoots);
|
|
465
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
466
|
+
await this.recordLifecycle('read_text_file', params);
|
|
467
|
+
return { content };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async createTerminal(params) {
|
|
471
|
+
const sessionRoots = this.sessionRoots.get(params.sessionId) || [];
|
|
472
|
+
const cwd = params.cwd ? assertAllowedTerminalCwd(params.sessionId, params.cwd, this.sessionRoots) : sessionRoots[0];
|
|
473
|
+
const terminalId = `term_${randomUUID()}`;
|
|
474
|
+
const child = spawn(params.command, params.args || [], {
|
|
475
|
+
cwd,
|
|
476
|
+
env: {
|
|
477
|
+
...process.env,
|
|
478
|
+
...Object.fromEntries((params.env || []).map((entry) => [entry.name, entry.value])),
|
|
479
|
+
},
|
|
480
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
481
|
+
});
|
|
482
|
+
const terminal = {
|
|
483
|
+
id: terminalId,
|
|
484
|
+
output: '',
|
|
485
|
+
truncated: false,
|
|
486
|
+
outputByteLimit: params.outputByteLimit || defaultOutputByteLimit,
|
|
487
|
+
exitCode: null,
|
|
488
|
+
signal: null,
|
|
489
|
+
child,
|
|
490
|
+
waitPromise: null,
|
|
491
|
+
};
|
|
492
|
+
terminal.waitPromise = new Promise((resolve) => {
|
|
493
|
+
child.on('exit', (exitCode, signal) => {
|
|
494
|
+
terminal.exitCode = exitCode;
|
|
495
|
+
terminal.signal = signal;
|
|
496
|
+
resolve({
|
|
497
|
+
exitCode,
|
|
498
|
+
signal,
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
child.stdout.on('data', (chunk) => {
|
|
503
|
+
appendTerminalOutput(terminal, chunk.toString('utf8'));
|
|
504
|
+
});
|
|
505
|
+
child.stderr.on('data', (chunk) => {
|
|
506
|
+
appendTerminalOutput(terminal, chunk.toString('utf8'));
|
|
507
|
+
});
|
|
508
|
+
this.terminals.set(terminalId, terminal);
|
|
509
|
+
await this.recordLifecycle('terminal_create', {
|
|
510
|
+
terminalId,
|
|
511
|
+
command: params.command,
|
|
512
|
+
args: params.args || [],
|
|
513
|
+
});
|
|
514
|
+
return { terminalId };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async terminalOutput(params) {
|
|
518
|
+
const terminal = getTerminal(this.terminals, params.terminalId);
|
|
519
|
+
return {
|
|
520
|
+
output: terminal.output,
|
|
521
|
+
truncated: terminal.truncated,
|
|
522
|
+
...(terminal.exitCode !== null || terminal.signal !== null
|
|
523
|
+
? {
|
|
524
|
+
exitStatus: {
|
|
525
|
+
exitCode: terminal.exitCode,
|
|
526
|
+
signal: terminal.signal,
|
|
527
|
+
},
|
|
528
|
+
}
|
|
529
|
+
: {}),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async waitForTerminalExit(params) {
|
|
534
|
+
const terminal = getTerminal(this.terminals, params.terminalId);
|
|
535
|
+
return terminal.waitPromise;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async killTerminal(params) {
|
|
539
|
+
const terminal = getTerminal(this.terminals, params.terminalId);
|
|
540
|
+
terminal.child.kill();
|
|
541
|
+
return {};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async releaseTerminal(params) {
|
|
545
|
+
const terminal = getTerminal(this.terminals, params.terminalId);
|
|
546
|
+
if (terminal.exitCode === null && terminal.signal === null) {
|
|
547
|
+
terminal.child.kill();
|
|
548
|
+
}
|
|
549
|
+
this.terminals.delete(params.terminalId);
|
|
550
|
+
return {};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async unstable_createElicitation(params) {
|
|
554
|
+
const result = await handleElicitationRequest({
|
|
555
|
+
policy: this.approvalPolicy,
|
|
556
|
+
params,
|
|
557
|
+
bridge: this.bridge,
|
|
558
|
+
});
|
|
559
|
+
await this.recordLifecycle('elicitation', {
|
|
560
|
+
request: params,
|
|
561
|
+
response: result,
|
|
562
|
+
});
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async unstable_completeElicitation(params) {
|
|
567
|
+
await this.recordLifecycle('elicitation_complete', params);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async recordAgentStderr(message) {
|
|
571
|
+
await this.recordLifecycle('agent_stderr', {
|
|
572
|
+
message,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async recordLifecycle(type, payload) {
|
|
577
|
+
await fs.appendFile(
|
|
578
|
+
this.logPath,
|
|
579
|
+
`${JSON.stringify({ timestamp: new Date().toISOString(), type, payload })}\n`,
|
|
580
|
+
'utf8',
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// WorkshopTaskBridge maps coder activity onto real Workshop task endpoints.
|
|
586
|
+
// Non-blocking session updates become typed worklog entries; blocking
|
|
587
|
+
// permission/elicitation requests become structured feedback requests that the
|
|
588
|
+
// bridge polls until a human answers on the web (the "remote" approval policy).
|
|
589
|
+
class WorkshopTaskBridge {
|
|
590
|
+
constructor({ requestWorkshop, taskId, version, approvalPolicy, feedbackTimeoutMs }) {
|
|
591
|
+
this.requestWorkshop = requestWorkshop;
|
|
592
|
+
this.taskId = taskId;
|
|
593
|
+
this.version = version;
|
|
594
|
+
this.approvalPolicy = approvalPolicy;
|
|
595
|
+
this.feedbackTimeoutMs = feedbackTimeoutMs ?? defaultFeedbackTimeoutMs;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// publishSessionUpdate records a typed worklog entry. Best-effort: a failure
|
|
599
|
+
// (e.g. the task is momentarily not in_progress) must not crash the coder run.
|
|
600
|
+
async publishSessionUpdate(params) {
|
|
601
|
+
const update = params?.update || {};
|
|
602
|
+
const updateType = String(update.sessionUpdate || 'session_update').slice(0, 48);
|
|
603
|
+
const body = formatSessionUpdateBody(params) || updateType;
|
|
604
|
+
try {
|
|
605
|
+
await this.requestWorkshop({
|
|
606
|
+
method: 'POST',
|
|
607
|
+
path: `/api/v1/tasks/${this.taskId}/worklogs`,
|
|
608
|
+
body: {
|
|
609
|
+
updateType,
|
|
610
|
+
body: body.slice(0, 2000),
|
|
611
|
+
payload: update,
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
} catch (error) {
|
|
615
|
+
process.stderr.write(`losclaws: failed to record worklog (${updateType}): ${error.message}\n`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// requestFeedbackAndWait opens a structured feedback request, then long-polls
|
|
620
|
+
// the task until a human submits a response on the web, returning that response.
|
|
621
|
+
// Throws on timeout so the caller can fall back to a safe default.
|
|
622
|
+
async requestFeedbackAndWait({ kind, prompt, options, requestSchema }) {
|
|
623
|
+
const body = {
|
|
624
|
+
expectedVersion: this.version ?? 0,
|
|
625
|
+
kind,
|
|
626
|
+
prompt: prompt || kind,
|
|
627
|
+
};
|
|
628
|
+
if (options !== undefined) {
|
|
629
|
+
body.options = options;
|
|
630
|
+
}
|
|
631
|
+
if (requestSchema !== undefined) {
|
|
632
|
+
body.requestSchema = requestSchema;
|
|
633
|
+
}
|
|
634
|
+
const created = await this.requestWorkshop({
|
|
635
|
+
method: 'POST',
|
|
636
|
+
path: `/api/v1/tasks/${this.taskId}/feedback-requests`,
|
|
637
|
+
body,
|
|
638
|
+
});
|
|
639
|
+
this.updateVersionFrom(created);
|
|
640
|
+
|
|
641
|
+
const deadline = Date.now() + this.feedbackTimeoutMs;
|
|
642
|
+
while (Date.now() < deadline) {
|
|
643
|
+
await delay(defaultFeedbackPollIntervalMs);
|
|
644
|
+
let detail;
|
|
645
|
+
try {
|
|
646
|
+
detail = await this.requestWorkshop({ path: `/api/v1/tasks/${this.taskId}` });
|
|
647
|
+
} catch (error) {
|
|
648
|
+
process.stderr.write(`losclaws: feedback poll failed: ${error.message}\n`);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const task = detail && detail.task ? detail.task : detail;
|
|
652
|
+
this.updateVersionFrom(task);
|
|
653
|
+
const session = detail?.feedbackSession || task?.feedbackSession || null;
|
|
654
|
+
if (session && session.status && session.status !== 'open') {
|
|
655
|
+
const entries = Array.isArray(session.entries) ? session.entries : [];
|
|
656
|
+
const last = entries.length > 0 ? entries[entries.length - 1] : null;
|
|
657
|
+
return {
|
|
658
|
+
status: session.status,
|
|
659
|
+
response: last?.response ?? null,
|
|
660
|
+
body: last?.body ?? '',
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
throw new CliError(`Timed out after ${Math.round(this.feedbackTimeoutMs / 1000)}s waiting for human feedback.`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
updateVersionFrom(value) {
|
|
668
|
+
const next = asNumberOrNull(value?.version ?? value?.task?.version);
|
|
669
|
+
if (next !== null) {
|
|
670
|
+
this.version = next;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async flush() {}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// formatSessionUpdateBody renders a concise human-readable line for a worklog
|
|
678
|
+
// entry; the full structured payload is stored alongside it.
|
|
679
|
+
function formatSessionUpdateBody(params) {
|
|
680
|
+
const update = params?.update || {};
|
|
681
|
+
switch (update.sessionUpdate) {
|
|
682
|
+
case 'agent_message_chunk':
|
|
683
|
+
case 'user_message_chunk':
|
|
684
|
+
return textFromContent(update.content);
|
|
685
|
+
case 'agent_thought_chunk':
|
|
686
|
+
return `(thinking) ${textFromContent(update.content)}`.trim();
|
|
687
|
+
case 'tool_call':
|
|
688
|
+
return `[tool] ${String(update.title || update.toolCallId || '')} (${String(update.status || 'pending')})`;
|
|
689
|
+
case 'tool_call_update':
|
|
690
|
+
return `[tool] ${String(update.toolCallId || '')} -> ${String(update.status || '')}`;
|
|
691
|
+
case 'plan':
|
|
692
|
+
return `[plan] ${(Array.isArray(update.entries) ? update.entries : []).length} step(s)`;
|
|
693
|
+
case 'usage_update':
|
|
694
|
+
return `[usage] ${String(update.used ?? '?')}/${String(update.size ?? '?')}`;
|
|
695
|
+
default:
|
|
696
|
+
return String(update.sessionUpdate || 'session_update');
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function textFromContent(content) {
|
|
701
|
+
if (!content) {
|
|
702
|
+
return '';
|
|
703
|
+
}
|
|
704
|
+
if (typeof content === 'string') {
|
|
705
|
+
return content;
|
|
706
|
+
}
|
|
707
|
+
if (typeof content.text === 'string') {
|
|
708
|
+
return content.text;
|
|
709
|
+
}
|
|
710
|
+
return '';
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function delay(ms) {
|
|
714
|
+
return new Promise((resolve) => {
|
|
715
|
+
setTimeout(resolve, ms);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function selectPermissionOutcome({ policy, params, bridge }) {
|
|
720
|
+
const options = Array.isArray(params.options) ? params.options : [];
|
|
721
|
+
if (options.length === 0) {
|
|
722
|
+
return { outcome: 'cancelled' };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const normalizedPolicy = String(policy || 'interactive');
|
|
726
|
+
if (normalizedPolicy === 'remote') {
|
|
727
|
+
return resolveRemotePermission({ params, options, bridge });
|
|
728
|
+
}
|
|
729
|
+
if (normalizedPolicy === 'auto-allow') {
|
|
730
|
+
return selectByKinds(options, ['allow_once', 'allow_always']) || selectFirstOption(options);
|
|
731
|
+
}
|
|
732
|
+
if (normalizedPolicy === 'auto-reject') {
|
|
733
|
+
return selectByKinds(options, ['reject_once', 'reject_always']) || selectFirstOption(options);
|
|
734
|
+
}
|
|
735
|
+
if (normalizedPolicy === 'read-only') {
|
|
736
|
+
if (readOnlyToolKinds.has(params.toolCall.kind || 'other')) {
|
|
737
|
+
return selectByKinds(options, ['allow_once', 'allow_always']) || selectFirstOption(options);
|
|
738
|
+
}
|
|
739
|
+
return selectByKinds(options, ['reject_once', 'reject_always']) || selectFirstOption(options);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
743
|
+
return selectPermissionOutcome({ policy: 'read-only', params });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const rl = readline.createInterface({
|
|
747
|
+
input: process.stdin,
|
|
748
|
+
output: process.stdout,
|
|
749
|
+
});
|
|
750
|
+
try {
|
|
751
|
+
console.log(`\nACP permission requested: ${params.toolCall.title}`);
|
|
752
|
+
options.forEach((option, index) => {
|
|
753
|
+
console.log(` ${index + 1}. ${option.name} (${option.kind})`);
|
|
754
|
+
});
|
|
755
|
+
while (true) {
|
|
756
|
+
const answer = await rl.question('Choose an option: ');
|
|
757
|
+
const index = Number(answer.trim());
|
|
758
|
+
if (Number.isInteger(index) && index >= 1 && index <= options.length) {
|
|
759
|
+
return {
|
|
760
|
+
outcome: 'selected',
|
|
761
|
+
optionId: options[index - 1].optionId,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
} finally {
|
|
766
|
+
rl.close();
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function selectByKinds(options, kinds) {
|
|
771
|
+
const match = options.find((entry) => kinds.includes(entry.kind));
|
|
772
|
+
if (!match) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
return {
|
|
776
|
+
outcome: 'selected',
|
|
777
|
+
optionId: match.optionId,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function selectFirstOption(options) {
|
|
782
|
+
return {
|
|
783
|
+
outcome: 'selected',
|
|
784
|
+
optionId: options[0].optionId,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// resolveRemotePermission posts the coder's permission request to the server as a
|
|
789
|
+
// single_select feedback request and blocks until a human answers on the web. On
|
|
790
|
+
// timeout or a declined request it falls back to a safe reject.
|
|
791
|
+
async function resolveRemotePermission({ params, options, bridge }) {
|
|
792
|
+
if (!bridge) {
|
|
793
|
+
return selectByKinds(options, ['reject_once', 'reject_always']) || selectFirstOption(options);
|
|
794
|
+
}
|
|
795
|
+
const promptOptions = options.map((option) => ({
|
|
796
|
+
optionId: option.optionId,
|
|
797
|
+
name: option.name,
|
|
798
|
+
kind: option.kind,
|
|
799
|
+
}));
|
|
800
|
+
try {
|
|
801
|
+
const result = await bridge.requestFeedbackAndWait({
|
|
802
|
+
kind: 'single_select',
|
|
803
|
+
prompt: String(params.toolCall?.title || 'The coder is requesting permission to proceed.'),
|
|
804
|
+
options: promptOptions,
|
|
805
|
+
});
|
|
806
|
+
const optionId = extractSelectedOptionId(result, options);
|
|
807
|
+
if (!optionId) {
|
|
808
|
+
return { outcome: 'cancelled' };
|
|
809
|
+
}
|
|
810
|
+
return { outcome: 'selected', optionId };
|
|
811
|
+
} catch (error) {
|
|
812
|
+
process.stderr.write(`losclaws: ${error.message} Falling back to reject.\n`);
|
|
813
|
+
return selectByKinds(options, ['reject_once', 'reject_always']) || { outcome: 'cancelled' };
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// extractSelectedOptionId maps a human's structured response back to a valid ACP
|
|
818
|
+
// permission optionId. Accepts {optionId}, a bare option id/name, or an index.
|
|
819
|
+
function extractSelectedOptionId(result, options) {
|
|
820
|
+
const valid = new Set(options.map((option) => option.optionId));
|
|
821
|
+
const response = result?.response;
|
|
822
|
+
const candidates = [];
|
|
823
|
+
if (response && typeof response === 'object') {
|
|
824
|
+
candidates.push(response.optionId, response.value, response.selected, response.choice);
|
|
825
|
+
} else if (response !== undefined && response !== null) {
|
|
826
|
+
candidates.push(response);
|
|
827
|
+
}
|
|
828
|
+
candidates.push(result?.body);
|
|
829
|
+
for (const candidate of candidates) {
|
|
830
|
+
const value = String(candidate ?? '').trim();
|
|
831
|
+
if (value && valid.has(value)) {
|
|
832
|
+
return value;
|
|
833
|
+
}
|
|
834
|
+
const byName = options.find((option) => option.name === value);
|
|
835
|
+
if (byName) {
|
|
836
|
+
return byName.optionId;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return '';
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function handleElicitationRequest({ policy, params, bridge }) {
|
|
843
|
+
if (String(policy || '') === 'remote' && bridge) {
|
|
844
|
+
return resolveRemoteElicitation({ params, bridge });
|
|
845
|
+
}
|
|
846
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
847
|
+
return buildDefaultElicitationResponse(params);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const rl = readline.createInterface({
|
|
851
|
+
input: process.stdin,
|
|
852
|
+
output: process.stdout,
|
|
853
|
+
});
|
|
854
|
+
try {
|
|
855
|
+
console.log(`\nACP agent input requested: ${params.message}`);
|
|
856
|
+
if (params.mode === 'url') {
|
|
857
|
+
console.log(`Open this URL to continue: ${params.url}`);
|
|
858
|
+
const answer = (await rl.question('Press Enter to accept, or type "decline" / "cancel": ')).trim().toLowerCase();
|
|
859
|
+
if (answer === 'decline') {
|
|
860
|
+
return { action: 'decline' };
|
|
861
|
+
}
|
|
862
|
+
if (answer === 'cancel') {
|
|
863
|
+
return { action: 'cancel' };
|
|
864
|
+
}
|
|
865
|
+
return { action: 'accept' };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const content = {};
|
|
869
|
+
const properties = params.requestedSchema?.properties || {};
|
|
870
|
+
for (const [key, schema] of Object.entries(properties)) {
|
|
871
|
+
const label = schema.title || key;
|
|
872
|
+
const defaultValue = schema.default;
|
|
873
|
+
if (schema.type === 'boolean') {
|
|
874
|
+
const answer = await rl.question(`${label} [${defaultValue ? 'Y/n' : 'y/N'}]: `);
|
|
875
|
+
content[key] = answer.trim() ? /^y(es)?$/i.test(answer.trim()) : Boolean(defaultValue);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (schema.type === 'array') {
|
|
879
|
+
const choices = normalizeEnumChoices(schema.items?.anyOf || schema.items?.enum || []);
|
|
880
|
+
choices.forEach((entry, index) => {
|
|
881
|
+
console.log(` ${index + 1}. ${entry.title}`);
|
|
882
|
+
});
|
|
883
|
+
const answer = await rl.question(`${label} (comma-separated numbers): `);
|
|
884
|
+
const indexes = answer
|
|
885
|
+
.split(',')
|
|
886
|
+
.map((entry) => Number(entry.trim()))
|
|
887
|
+
.filter((entry) => Number.isInteger(entry) && entry >= 1 && entry <= choices.length);
|
|
888
|
+
content[key] = indexes.map((entry) => choices[entry - 1].value);
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const choices = normalizeEnumChoices(schema.oneOf || schema.enum || []);
|
|
892
|
+
if (choices.length > 0) {
|
|
893
|
+
choices.forEach((entry, index) => {
|
|
894
|
+
console.log(` ${index + 1}. ${entry.title}`);
|
|
895
|
+
});
|
|
896
|
+
const answer = await rl.question(`${label}${defaultValue !== undefined ? ` [${defaultValue}]` : ''}: `);
|
|
897
|
+
const index = Number(answer.trim());
|
|
898
|
+
content[key] =
|
|
899
|
+
Number.isInteger(index) && index >= 1 && index <= choices.length
|
|
900
|
+
? choices[index - 1].value
|
|
901
|
+
: defaultValue ?? choices[0].value;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const answer = await rl.question(`${label}${defaultValue !== undefined ? ` [${defaultValue}]` : ''}: `);
|
|
905
|
+
content[key] = coerceScalarValue(schema.type, answer.trim(), defaultValue);
|
|
906
|
+
}
|
|
907
|
+
return {
|
|
908
|
+
action: 'accept',
|
|
909
|
+
content,
|
|
910
|
+
};
|
|
911
|
+
} finally {
|
|
912
|
+
rl.close();
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// resolveRemoteElicitation posts the coder's elicitation to the server as a
|
|
917
|
+
// form/url feedback request and blocks until a human answers on the web.
|
|
918
|
+
async function resolveRemoteElicitation({ params, bridge }) {
|
|
919
|
+
const kind = params.mode === 'url' ? 'url' : 'form';
|
|
920
|
+
const requestSchema = params.mode === 'url'
|
|
921
|
+
? { url: String(params.url || '') }
|
|
922
|
+
: { schema: params.requestedSchema || {} };
|
|
923
|
+
try {
|
|
924
|
+
const result = await bridge.requestFeedbackAndWait({
|
|
925
|
+
kind,
|
|
926
|
+
prompt: String(params.message || 'The coder is requesting input.'),
|
|
927
|
+
requestSchema,
|
|
928
|
+
});
|
|
929
|
+
const response = result?.response;
|
|
930
|
+
if (response && typeof response === 'object') {
|
|
931
|
+
if (typeof response.action === 'string') {
|
|
932
|
+
return response;
|
|
933
|
+
}
|
|
934
|
+
return { action: 'accept', content: response };
|
|
935
|
+
}
|
|
936
|
+
if (kind === 'url') {
|
|
937
|
+
return { action: 'accept' };
|
|
938
|
+
}
|
|
939
|
+
return { action: 'decline' };
|
|
940
|
+
} catch (error) {
|
|
941
|
+
process.stderr.write(`losclaws: ${error.message} Declining elicitation.\n`);
|
|
942
|
+
return params.mode === 'url' ? { action: 'decline' } : { action: 'cancel' };
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function buildDefaultElicitationResponse(params) {
|
|
947
|
+
if (params.mode === 'url') {
|
|
948
|
+
return { action: 'decline' };
|
|
949
|
+
}
|
|
950
|
+
const properties = params.requestedSchema?.properties || {};
|
|
951
|
+
const content = {};
|
|
952
|
+
for (const [key, schema] of Object.entries(properties)) {
|
|
953
|
+
if (schema.default !== undefined) {
|
|
954
|
+
content[key] = schema.default;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (Object.keys(content).length === 0) {
|
|
958
|
+
return { action: 'decline' };
|
|
959
|
+
}
|
|
960
|
+
return {
|
|
961
|
+
action: 'accept',
|
|
962
|
+
content,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function normalizeEnumChoices(rawChoices) {
|
|
967
|
+
if (!Array.isArray(rawChoices)) {
|
|
968
|
+
return [];
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return rawChoices.map((entry) =>
|
|
972
|
+
typeof entry === 'string'
|
|
973
|
+
? { value: entry, title: entry }
|
|
974
|
+
: { value: entry.const, title: entry.title || entry.const },
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function coerceScalarValue(type, value, defaultValue) {
|
|
979
|
+
if (!value) {
|
|
980
|
+
return defaultValue ?? '';
|
|
981
|
+
}
|
|
982
|
+
if (type === 'integer' || type === 'number') {
|
|
983
|
+
return Number(value);
|
|
984
|
+
}
|
|
985
|
+
return value;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function appendTerminalOutput(terminal, chunk) {
|
|
989
|
+
terminal.output += chunk;
|
|
990
|
+
const byteLength = Buffer.byteLength(terminal.output, 'utf8');
|
|
991
|
+
if (byteLength <= terminal.outputByteLimit) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
terminal.truncated = true;
|
|
995
|
+
let output = terminal.output;
|
|
996
|
+
while (Buffer.byteLength(output, 'utf8') > terminal.outputByteLimit) {
|
|
997
|
+
output = output.slice(Math.max(1, Math.floor(output.length / 8)));
|
|
998
|
+
}
|
|
999
|
+
terminal.output = output;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function getTerminal(terminals, terminalId) {
|
|
1003
|
+
const terminal = terminals.get(terminalId);
|
|
1004
|
+
if (!terminal) {
|
|
1005
|
+
throw new CliError(`Unknown ACP terminal: ${terminalId}`);
|
|
1006
|
+
}
|
|
1007
|
+
return terminal;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function assertAllowedPath(sessionId, filePath, sessionRoots) {
|
|
1011
|
+
const absolutePath = path.resolve(filePath);
|
|
1012
|
+
const roots = sessionRoots.get(sessionId) || [];
|
|
1013
|
+
if (roots.length > 0 && !roots.some((root) => isInsideRoot(absolutePath, root))) {
|
|
1014
|
+
throw new CliError(`ACP file access is outside the allowed roots: ${absolutePath}`);
|
|
1015
|
+
}
|
|
1016
|
+
return absolutePath;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function assertAllowedTerminalCwd(sessionId, cwd, sessionRoots) {
|
|
1020
|
+
return assertAllowedPath(sessionId, cwd, sessionRoots);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function isInsideRoot(candidate, root) {
|
|
1024
|
+
const relative = path.relative(root, candidate);
|
|
1025
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function printSessionUpdate(params, json) {
|
|
1029
|
+
if (json) {
|
|
1030
|
+
// Stream progress to stderr so stdout carries only the final JSON result.
|
|
1031
|
+
process.stderr.write(`${JSON.stringify({ event: 'session/update', data: params })}\n`);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const update = params.update;
|
|
1036
|
+
if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
|
|
1037
|
+
console.log(update.content.text);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (update.sessionUpdate === 'tool_call') {
|
|
1041
|
+
console.log(`[tool] ${update.title} (${update.status})`);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (update.sessionUpdate === 'tool_call_update') {
|
|
1045
|
+
console.log(`[tool] ${update.toolCallId} -> ${update.status}`);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (update.sessionUpdate === 'plan') {
|
|
1049
|
+
console.log('[plan]');
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (update.sessionUpdate === 'usage_update') {
|
|
1053
|
+
console.log(`[usage] ${update.used}/${update.size}`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
async function stopChildProcess(child) {
|
|
1058
|
+
if (child.stdin && !child.stdin.destroyed) {
|
|
1059
|
+
child.stdin.end();
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const waitForExit = new Promise((resolve) => {
|
|
1063
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
1064
|
+
resolve();
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
child.once('exit', resolve);
|
|
1068
|
+
child.once('close', resolve);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1072
|
+
child.kill();
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const timeout = setTimeout(() => {
|
|
1076
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1077
|
+
child.kill('SIGKILL');
|
|
1078
|
+
}
|
|
1079
|
+
}, 1000);
|
|
1080
|
+
|
|
1081
|
+
await waitForExit;
|
|
1082
|
+
clearTimeout(timeout);
|
|
1083
|
+
}
|