@diologue/local-agent 0.2.0 → 0.5.0

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/dist/cli.mjs CHANGED
@@ -167,7 +167,7 @@ import { access as access3, constants as constants2 } from "node:fs/promises";
167
167
  import { spawn as spawn2 } from "node:child_process";
168
168
  import path4 from "node:path";
169
169
  import { fileURLToPath as fileURLToPath2 } from "node:url";
170
- var __filename2, __dirname2, REPO_ROOT2, DEFAULT_ENGINE_DIR, ENTRY_REL, DEFAULT_STARTUP_TIMEOUT_MS, LISTENING_REGEX, defaultRuntimeCheck, exists3, defaultClientFactory, LocalEngineLocator, truncate;
170
+ var __filename2, __dirname2, REPO_ROOT2, DEFAULT_ENGINE_DIR, ENTRY_REL, DEFAULT_STARTUP_TIMEOUT_MS, LISTENING_REGEX, defaultRuntimeCheck, exists3, isRecord, parseExistingInlineConfig, mergeInlineConfig, buildEngineEnv, defaultClientFactory, LocalEngineLocator, truncate;
171
171
  var init_engine_locator_local = __esm({
172
172
  "src/adapters/engine-locator-local.ts"() {
173
173
  "use strict";
@@ -192,6 +192,37 @@ var init_engine_locator_local = __esm({
192
192
  return false;
193
193
  }
194
194
  };
195
+ isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
196
+ parseExistingInlineConfig = (existing) => {
197
+ if (!existing) return {};
198
+ try {
199
+ const parsed = JSON.parse(existing);
200
+ return isRecord(parsed) ? parsed : {};
201
+ } catch {
202
+ return {};
203
+ }
204
+ };
205
+ mergeInlineConfig = (existing, injected) => {
206
+ if (!injected) return existing;
207
+ const base = parseExistingInlineConfig(existing);
208
+ const baseProvider = isRecord(base.provider) ? base.provider : {};
209
+ return JSON.stringify({
210
+ ...base,
211
+ ...injected,
212
+ provider: {
213
+ ...baseProvider,
214
+ ...injected.provider ?? {}
215
+ }
216
+ });
217
+ };
218
+ buildEngineEnv = (config) => {
219
+ const env = { ...process.env };
220
+ const content = mergeInlineConfig(env.OPENCODE_CONFIG_CONTENT, config);
221
+ if (content) {
222
+ env.OPENCODE_CONFIG_CONTENT = content;
223
+ }
224
+ return env;
225
+ };
195
226
  defaultClientFactory = async (baseUrl) => {
196
227
  const sdk = await import("@opencode-ai/sdk");
197
228
  return sdk.createOpencodeClient({ baseUrl });
@@ -281,13 +312,12 @@ var init_engine_locator_local = __esm({
281
312
  const child = spawn2(command, args, {
282
313
  cwd,
283
314
  stdio: ["ignore", "pipe", "pipe"],
284
- // Inherit env so the engine sees any LOCAL_AGENT_* / Provider
285
- // credentials the parent has, plus inject the shim config via
286
- // OPENCODE_CONFIG-style env once V2.5 wires that. (For now the
287
- // config flows through createOpencodeServer in the npm path
288
- // and is wired post-spawn here TODO once we cut the npm
289
- // SDK entirely, we'll pipe the config in via stdin or args.)
290
- env: process.env
315
+ // Inherit env so the engine sees any LOCAL_AGENT_* / provider
316
+ // credentials the parent has, then inject the brokered provider as
317
+ // inline opencode config. Without this, the default local engine path
318
+ // does not know the diologue/diologue-routed model, so the prompt
319
+ // fails before edit tools can create files in the selected repo.
320
+ env: buildEngineEnv(options.config)
291
321
  });
292
322
  let url;
293
323
  try {
@@ -504,6 +534,9 @@ var createCorsMiddleware = (options) => {
504
534
  `Content-Type, ${LOCAL_AGENT_TOKEN_HEADER}`
505
535
  );
506
536
  res.setHeader("Access-Control-Max-Age", "300");
537
+ if (req.header("access-control-request-private-network") === "true") {
538
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
539
+ }
507
540
  }
508
541
  if (req.method === "OPTIONS") {
509
542
  res.status(204).end();
@@ -641,8 +674,56 @@ var getStatusShort = async (cwd) => {
641
674
  const out = await runGit(cwd, ["status", "--short", "-uall"]);
642
675
  return parseShortStatus(out);
643
676
  };
677
+ var runGitAllowingExit = (cwd, args, allowedExitCodes) => new Promise((resolve, reject) => {
678
+ execFile(
679
+ "git",
680
+ args,
681
+ {
682
+ cwd,
683
+ maxBuffer: 16 * 1024 * 1024,
684
+ env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat" }
685
+ },
686
+ (err, stdout, stderr) => {
687
+ if (!err) {
688
+ resolve(stdout);
689
+ return;
690
+ }
691
+ const e = err;
692
+ const exitCode = e.code ?? -1;
693
+ if (allowedExitCodes.has(exitCode)) {
694
+ resolve(stdout);
695
+ return;
696
+ }
697
+ reject(new GitCommandError(args, exitCode, stderr));
698
+ }
699
+ );
700
+ });
701
+ var getUntrackedFiles = async (cwd) => {
702
+ const out = await runGit(cwd, [
703
+ "ls-files",
704
+ "--others",
705
+ "--exclude-standard",
706
+ "-z"
707
+ ]);
708
+ return out.split("\0").filter(Boolean);
709
+ };
710
+ var getUntrackedFileDiff = async (cwd, file) => {
711
+ const diff = await runGitAllowingExit(
712
+ cwd,
713
+ ["diff", "--no-index", "--", "/dev/null", file],
714
+ /* @__PURE__ */ new Set([1])
715
+ );
716
+ return diff.replace(/^diff --git a\/dev\/null b\/(.+)$/m, "diff --git a/$1 b/$1").replace(/^--- \/dev\/null$/m, "--- /dev/null");
717
+ };
644
718
  var getDiff = async (cwd) => {
645
- return runGit(cwd, ["diff", "HEAD"]);
719
+ const [trackedDiff, untrackedFiles] = await Promise.all([
720
+ runGit(cwd, ["diff", "HEAD"]),
721
+ getUntrackedFiles(cwd)
722
+ ]);
723
+ const untrackedDiffs = await Promise.all(
724
+ untrackedFiles.map((file) => getUntrackedFileDiff(cwd, file))
725
+ );
726
+ return [trackedDiff, ...untrackedDiffs].filter((part) => part.trim().length > 0).join("\n");
646
727
  };
647
728
  var runGitWithStdin = async (cwd, args, input) => {
648
729
  const { execFile: execFile2 } = await import("node:child_process");
@@ -682,6 +763,20 @@ var canApplyDiff = async (cwd, unified) => {
682
763
  var applyDiff = async (cwd, unified) => {
683
764
  await runGitWithStdin(cwd, ["apply"], unified);
684
765
  };
766
+ var canRevertDiff = async (cwd, unified) => {
767
+ try {
768
+ await runGitWithStdin(cwd, ["apply", "--reverse", "--check"], unified);
769
+ return { ok: true };
770
+ } catch (err) {
771
+ if (err instanceof GitCommandError) {
772
+ return { ok: false, stderr: err.stderr };
773
+ }
774
+ throw err;
775
+ }
776
+ };
777
+ var revertDiff = async (cwd, unified) => {
778
+ await runGitWithStdin(cwd, ["apply", "--reverse"], unified);
779
+ };
685
780
  var parseDiffPaths = (unified) => {
686
781
  const paths = [];
687
782
  for (const line of unified.split("\n")) {
@@ -761,6 +856,9 @@ var applyPatchSchema = z.object({
761
856
  unified: z.string().min(1).max(8 * 1024 * 1024),
762
857
  baselineHash: z.string().optional()
763
858
  });
859
+ var revertPatchSchema = z.object({
860
+ unified: z.string().min(1).max(8 * 1024 * 1024)
861
+ });
764
862
  var buildRepoStatus = async (resolvedPath) => {
765
863
  const [branch, head, dirty] = await Promise.all([
766
864
  getBranch(resolvedPath),
@@ -910,6 +1008,40 @@ var createRepoRouter = (state) => {
910
1008
  throw err;
911
1009
  }
912
1010
  });
1011
+ router.post("/revert", async (req, res) => {
1012
+ const repo = requireSelectedRepo(state, res);
1013
+ if (!repo) return;
1014
+ const parsed = revertPatchSchema.safeParse(req.body);
1015
+ if (!parsed.success) {
1016
+ res.status(400).json({ error: "invalid_body", issues: parsed.error.issues });
1017
+ return;
1018
+ }
1019
+ try {
1020
+ const check = await canRevertDiff(repo.path, parsed.data.unified);
1021
+ if (!check.ok) {
1022
+ res.status(409).json({
1023
+ error: "diff_does_not_revert",
1024
+ message: "These changes can't be reverted cleanly. The working tree may have changed since the agent wrote them, or they were already reverted.",
1025
+ stderr: check.stderr
1026
+ });
1027
+ return;
1028
+ }
1029
+ await revertDiff(repo.path, parsed.data.unified);
1030
+ const paths = parseDiffPaths(parsed.data.unified);
1031
+ const body = {
1032
+ ok: true,
1033
+ filesChanged: paths.length,
1034
+ paths
1035
+ };
1036
+ res.json(body);
1037
+ } catch (err) {
1038
+ if (err instanceof GitCommandError) {
1039
+ sendGitError(res, err);
1040
+ return;
1041
+ }
1042
+ throw err;
1043
+ }
1044
+ });
913
1045
  return router;
914
1046
  };
915
1047
 
@@ -919,6 +1051,9 @@ import { z as z2 } from "zod";
919
1051
 
920
1052
  // src/broker.ts
921
1053
  import { randomUUID } from "node:crypto";
1054
+ var logBroker = (message) => {
1055
+ console.error(`[llm-broker] ${message}`);
1056
+ };
922
1057
  var LlmBroker = class {
923
1058
  pending = /* @__PURE__ */ new Map();
924
1059
  emit;
@@ -948,13 +1083,18 @@ var LlmBroker = class {
948
1083
  return new Promise((resolve, reject) => {
949
1084
  const timer = setTimeout(() => {
950
1085
  this.pending.delete(requestId);
1086
+ logBroker(
1087
+ `timeout request=${requestId.slice(0, 8)} pending=${this.pending.size} after=${this.timeoutMs}ms`
1088
+ );
951
1089
  reject(
952
1090
  new Error(
953
1091
  `llm_broker_timeout: no response within ${this.timeoutMs}ms`
954
1092
  )
955
1093
  );
956
1094
  }, this.timeoutMs);
957
- timer.unref?.();
1095
+ if (this.timeoutMs >= 1e3) {
1096
+ timer.unref?.();
1097
+ }
958
1098
  this.pending.set(requestId, { resolve, reject, timer, observer });
959
1099
  const effectiveProvider = this.overrideProvider ?? payload.provider;
960
1100
  const effectiveModel = this.overrideModel ?? payload.model;
@@ -971,6 +1111,9 @@ var LlmBroker = class {
971
1111
  }
972
1112
  };
973
1113
  try {
1114
+ logBroker(
1115
+ `emit request=${requestId.slice(0, 8)} provider=${effectiveProvider ?? "(default)"} model=${effectiveModel ?? "(default)"} messages=${payload.messages.length} tools=${payload.tools?.length ?? 0}`
1116
+ );
974
1117
  this.emit(envelope);
975
1118
  } catch (err) {
976
1119
  clearTimeout(timer);
@@ -995,7 +1138,14 @@ var LlmBroker = class {
995
1138
  * route (e.g. for providers that don't support streaming). */
996
1139
  fulfill(requestId, response) {
997
1140
  const entry = this.pending.get(requestId);
998
- if (!entry) return false;
1141
+ if (!entry) {
1142
+ logBroker(`fulfill_miss request=${requestId.slice(0, 8)}`);
1143
+ return false;
1144
+ }
1145
+ const toolNames = (response.toolCalls ?? []).map((tc) => tc.function?.name ?? "?").join(",");
1146
+ logBroker(
1147
+ `fulfill request=${requestId.slice(0, 8)} text=${response.text.length} toolCalls=${response.toolCalls?.length ?? 0}${toolNames ? ` tools=[${toolNames}]` : ""} finish=${response.finishReason ?? "(none)"}`
1148
+ );
999
1149
  clearTimeout(entry.timer);
1000
1150
  this.pending.delete(requestId);
1001
1151
  if (entry.observer) {
@@ -1024,7 +1174,15 @@ var LlmBroker = class {
1024
1174
  * invoked; a `complete` chunk also resolves the pending Promise. */
1025
1175
  pushChunk(requestId, chunk) {
1026
1176
  const entry = this.pending.get(requestId);
1027
- if (!entry) return false;
1177
+ if (!entry) {
1178
+ logBroker(
1179
+ `chunk_miss request=${requestId.slice(0, 8)} type=${chunk.type}`
1180
+ );
1181
+ return false;
1182
+ }
1183
+ logBroker(
1184
+ `chunk request=${requestId.slice(0, 8)} type=${chunk.type}${chunk.type === "text_delta" ? ` chars=${chunk.text.length}` : ""}${chunk.type === "tool_call_delta" ? ` index=${chunk.index} name=${chunk.name ?? "(delta)"} argsDelta=${chunk.argumentsDelta?.length ?? 0}` : ""}`
1185
+ );
1028
1186
  if (entry.observer) {
1029
1187
  try {
1030
1188
  entry.observer(chunk);
@@ -1046,7 +1204,11 @@ var LlmBroker = class {
1046
1204
  * failure rather than a completion. */
1047
1205
  fail(requestId, message) {
1048
1206
  const entry = this.pending.get(requestId);
1049
- if (!entry) return false;
1207
+ if (!entry) {
1208
+ logBroker(`fail_miss request=${requestId.slice(0, 8)}`);
1209
+ return false;
1210
+ }
1211
+ logBroker(`fail request=${requestId.slice(0, 8)} message=${message}`);
1050
1212
  clearTimeout(entry.timer);
1051
1213
  this.pending.delete(requestId);
1052
1214
  entry.reject(new Error(message));
@@ -1055,6 +1217,9 @@ var LlmBroker = class {
1055
1217
  /** Reject all in-flight requests. Called when the parent SSE stream
1056
1218
  * closes so a stranded request doesn't sit forever. */
1057
1219
  close(reason = "broker_closed") {
1220
+ if (this.pending.size > 0) {
1221
+ logBroker(`close reason=${reason} pending=${this.pending.size}`);
1222
+ }
1058
1223
  this.closed = true;
1059
1224
  for (const [, entry] of this.pending.entries()) {
1060
1225
  clearTimeout(entry.timer);
@@ -1101,7 +1266,10 @@ var agentMessageBodySchema = z2.object({
1101
1266
  /** Optional user-chosen routing for this turn. When absent the cloud
1102
1267
  * picks the default (CODING_AGENT_DEFAULT_PROVIDER/MODEL). */
1103
1268
  preferredProvider: z2.string().min(1).max(100).optional(),
1104
- preferredModel: z2.string().min(1).max(200).optional()
1269
+ preferredModel: z2.string().min(1).max(200).optional(),
1270
+ /** Tool-gating policy. Absent → "auto" (run everything) so older clients
1271
+ * keep their current behaviour. */
1272
+ permissionMode: z2.enum(["auto", "confirm"]).optional()
1105
1273
  });
1106
1274
  var writeEvent = (res, event) => {
1107
1275
  res.write(`data: ${JSON.stringify(event)}
@@ -1111,6 +1279,9 @@ var writeEvent = (res, event) => {
1111
1279
  var writeDone = (res) => {
1112
1280
  res.write("data: [DONE]\n\n");
1113
1281
  };
1282
+ var logAgentRoute = (message) => {
1283
+ console.error(`[agent-route] ${message}`);
1284
+ };
1114
1285
  var createAgentRouter = (deps) => {
1115
1286
  const router = createRouter2();
1116
1287
  router.post("/message", async (req, res) => {
@@ -1124,6 +1295,9 @@ var createAgentRouter = (deps) => {
1124
1295
  res.status(409).json({ error: "no_repo_selected" });
1125
1296
  return;
1126
1297
  }
1298
+ logAgentRoute(
1299
+ `message start session=${parsed.data.sessionId.slice(0, 8)} repo=${repo.path} promptChars=${parsed.data.prompt.length} provider=${parsed.data.preferredProvider ?? "(default)"} model=${parsed.data.preferredModel ?? "(default)"}`
1300
+ );
1127
1301
  res.setHeader("Content-Type", "text/event-stream");
1128
1302
  res.setHeader("Cache-Control", "no-cache, no-transform");
1129
1303
  res.setHeader("Connection", "keep-alive");
@@ -1149,21 +1323,34 @@ var createAgentRouter = (deps) => {
1149
1323
  prompt: parsed.data.prompt,
1150
1324
  history,
1151
1325
  signal: controller.signal,
1152
- broker: (payload) => broker.request(payload),
1326
+ broker: (payload, observer) => broker.request(payload, observer),
1153
1327
  preferredProvider: parsed.data.preferredProvider,
1154
- preferredModel: parsed.data.preferredModel
1328
+ preferredModel: parsed.data.preferredModel,
1329
+ permissionMode: parsed.data.permissionMode
1155
1330
  })) {
1156
1331
  if (controller.signal.aborted) {
1332
+ logAgentRoute(
1333
+ `message aborted session=${parsed.data.sessionId.slice(0, 8)}`
1334
+ );
1157
1335
  break;
1158
1336
  }
1337
+ logAgentRoute(
1338
+ `stream event session=${parsed.data.sessionId.slice(0, 8)} type=${event.type}`
1339
+ );
1159
1340
  writeEvent(res, event);
1160
1341
  }
1342
+ logAgentRoute(
1343
+ `message done session=${parsed.data.sessionId.slice(0, 8)}`
1344
+ );
1161
1345
  writeDone(res);
1162
1346
  } catch (err) {
1163
1347
  if (err && typeof err === "object" && "name" in err && err.name === "AbortError") {
1164
1348
  writeDone(res);
1165
1349
  } else {
1166
1350
  const message = err instanceof Error ? err.message : "Adapter stream failed";
1351
+ logAgentRoute(
1352
+ `message error session=${parsed.data.sessionId.slice(0, 8)} error=${message}`
1353
+ );
1167
1354
  writeEvent(res, { type: "error", message });
1168
1355
  writeDone(res);
1169
1356
  }
@@ -1180,7 +1367,10 @@ var llmResponseBodySchema = z2.object({
1180
1367
  text: z2.string(),
1181
1368
  provider: z2.string().min(1),
1182
1369
  model: z2.string().min(1),
1183
- tokenUsage: z2.object({ input: z2.number().nonnegative(), output: z2.number().nonnegative() }).optional(),
1370
+ tokenUsage: z2.object({
1371
+ input: z2.number().nonnegative(),
1372
+ output: z2.number().nonnegative()
1373
+ }).optional(),
1184
1374
  error: z2.string().optional(),
1185
1375
  toolCalls: z2.array(
1186
1376
  z2.object({
@@ -1307,6 +1497,36 @@ var createLlmChunkRouter = (deps) => {
1307
1497
  });
1308
1498
  return router;
1309
1499
  };
1500
+ var permissionDecisionBodySchema = z2.object({
1501
+ sessionId: z2.string().min(1),
1502
+ permissionId: z2.string().min(1),
1503
+ response: z2.enum(["once", "always", "reject"])
1504
+ });
1505
+ var createPermissionRouter = (deps) => {
1506
+ const router = createRouter2();
1507
+ router.post("/", async (req, res) => {
1508
+ const parsed = permissionDecisionBodySchema.safeParse(req.body);
1509
+ if (!parsed.success) {
1510
+ res.status(400).json({ error: "invalid_body", issues: parsed.error.issues });
1511
+ return;
1512
+ }
1513
+ if (!deps.adapter.replyPermission) {
1514
+ res.status(501).json({ error: "permissions_unsupported" });
1515
+ return;
1516
+ }
1517
+ const matched = await deps.adapter.replyPermission(
1518
+ parsed.data.sessionId,
1519
+ parsed.data.permissionId,
1520
+ parsed.data.response
1521
+ );
1522
+ if (!matched) {
1523
+ res.status(404).json({ error: "no_active_permission" });
1524
+ return;
1525
+ }
1526
+ res.json({ ok: true });
1527
+ });
1528
+ return router;
1529
+ };
1310
1530
 
1311
1531
  // src/routes/llm-shim.ts
1312
1532
  import { Router as createRouter3 } from "express";
@@ -2173,13 +2393,30 @@ var MockOpenCodeAdapter = class {
2173
2393
  };
2174
2394
 
2175
2395
  // src/adapters/opencode-event-mapper.ts
2396
+ var MAX_TOOL_OUTPUT_CHARS = 4e3;
2397
+ var truncateOutput = (raw) => {
2398
+ if (!raw) return void 0;
2399
+ if (raw.length <= MAX_TOOL_OUTPUT_CHARS) return raw;
2400
+ const omitted = raw.length - MAX_TOOL_OUTPUT_CHARS;
2401
+ return `${raw.slice(0, MAX_TOOL_OUTPUT_CHARS)}
2402
+ \u2026 [${omitted} more characters truncated]`;
2403
+ };
2176
2404
  var createMapState = (ourSessionId, openCodeSessionId) => ({
2177
2405
  ourSessionId,
2178
2406
  openCodeSessionId,
2179
2407
  startedTools: /* @__PURE__ */ new Set(),
2180
2408
  endedTools: /* @__PURE__ */ new Set(),
2181
- announcedPatches: /* @__PURE__ */ new Set()
2409
+ announcedPatches: /* @__PURE__ */ new Set(),
2410
+ announcedPermissions: /* @__PURE__ */ new Set()
2182
2411
  });
2412
+ var extractPermissionCommand = (metadata) => {
2413
+ if (!metadata) return void 0;
2414
+ for (const key of ["command", "cmd", "url", "pattern", "filePath"]) {
2415
+ const v = metadata[key];
2416
+ if (typeof v === "string" && v) return v;
2417
+ }
2418
+ return void 0;
2419
+ };
2183
2420
  var EMPTY = { events: [], done: false };
2184
2421
  var mapEvent = (item, state) => {
2185
2422
  const sid = item.properties?.sessionID;
@@ -2189,6 +2426,25 @@ var mapEvent = (item, state) => {
2189
2426
  if (item.type === "session.idle" && sid === state.openCodeSessionId) {
2190
2427
  return { events: [{ type: "done" }], done: true };
2191
2428
  }
2429
+ if (item.type === "permission.updated") {
2430
+ const permissionId = item.properties?.id;
2431
+ if (!permissionId || state.announcedPermissions.has(permissionId)) {
2432
+ return EMPTY;
2433
+ }
2434
+ state.announcedPermissions.add(permissionId);
2435
+ return {
2436
+ events: [
2437
+ {
2438
+ type: "permission_request",
2439
+ permissionId,
2440
+ tool: item.properties?.type ?? "tool",
2441
+ title: item.properties?.title,
2442
+ command: extractPermissionCommand(item.properties?.metadata)
2443
+ }
2444
+ ],
2445
+ done: false
2446
+ };
2447
+ }
2192
2448
  if (item.type !== "message.part.updated") {
2193
2449
  return EMPTY;
2194
2450
  }
@@ -2227,7 +2483,10 @@ var mapEvent = (item, state) => {
2227
2483
  type: "tool_call_end",
2228
2484
  toolCallId: callId,
2229
2485
  ok: status === "completed",
2230
- summary: status === "error" ? part.state?.error ?? "Tool failed" : part.state?.title
2486
+ summary: status === "error" ? part.state?.error ?? "Tool failed" : part.state?.title,
2487
+ output: truncateOutput(
2488
+ status === "error" ? part.state?.error : part.state?.output
2489
+ )
2231
2490
  };
2232
2491
  return { events: [event], done: false };
2233
2492
  }
@@ -2285,6 +2544,12 @@ var createEngineLocator = async (name) => {
2285
2544
  init_engine_locator_npm();
2286
2545
  var DIOLOGUE_PROVIDER_ID = "diologue";
2287
2546
  var DIOLOGUE_MODEL_ID = "diologue-routed";
2547
+ var END_OF_TURN_GRACE_MS = 600;
2548
+ var PERMISSION_DECISION_TIMEOUT_MS = 18e4;
2549
+ var EDIT_DIRECTIVE = "You are an autonomous coding agent operating on a real git repository. Carry out the request by editing files directly with your tools (write / edit / patch / bash) \u2014 do not just describe the change, outline steps, or print code for the user to copy. Read only what you need, make the edits on disk, and finish once the change has actually been written.";
2550
+ var logAdapter = (message) => {
2551
+ console.error(`[opencode-adapter] ${message}`);
2552
+ };
2288
2553
  var OpenCodeProcessAdapter = class {
2289
2554
  constructor(options = {}) {
2290
2555
  this.options = options;
@@ -2294,6 +2559,9 @@ var OpenCodeProcessAdapter = class {
2294
2559
  /** Maps our sessionId → opencode session id so successive turns within
2295
2560
  * one coding-agent session reuse the same opencode conversation. */
2296
2561
  sessionMap = /* @__PURE__ */ new Map();
2562
+ /** In-flight turns by our sessionId, so replyPermission() can reach the
2563
+ * engine client to resolve a paused tool call. */
2564
+ activeTurns = /* @__PURE__ */ new Map();
2297
2565
  async resolveLocator() {
2298
2566
  if (this.options.engineLocator) {
2299
2567
  return this.options.engineLocator;
@@ -2335,7 +2603,19 @@ var OpenCodeProcessAdapter = class {
2335
2603
  }
2336
2604
  }
2337
2605
  },
2338
- model: `${DIOLOGUE_PROVIDER_ID}/${DIOLOGUE_MODEL_ID}`
2606
+ model: `${DIOLOGUE_PROVIDER_ID}/${DIOLOGUE_MODEL_ID}`,
2607
+ // Permission policy. The ADAPTER is the policy engine, not this config:
2608
+ // - edit stays "allow" — edits land in the working tree and are
2609
+ // reversible via the chat's Keep/Revert, so we never prompt on them.
2610
+ // - bash + webfetch are "ask" so opencode pauses them. In "auto" mode
2611
+ // the adapter auto-approves instantly (behaves like allow); in
2612
+ // "confirm" mode it forwards an AgentPermissionRequest to the browser
2613
+ // and resumes only once the user replies. See streamMessage().
2614
+ permission: {
2615
+ edit: "allow",
2616
+ bash: "ask",
2617
+ webfetch: "ask"
2618
+ }
2339
2619
  };
2340
2620
  }
2341
2621
  async ensureServer() {
@@ -2344,7 +2624,10 @@ var OpenCodeProcessAdapter = class {
2344
2624
  }
2345
2625
  this.serverPromise = (async () => {
2346
2626
  const locator = await this.resolveLocator();
2347
- return locator.start({ config: this.buildShimConfig() });
2627
+ logAdapter(`starting engine helperBase=${this.resolveHelperBaseUrl()}`);
2628
+ const handle = await locator.start({ config: this.buildShimConfig() });
2629
+ logAdapter(`engine ready url=${handle.url}`);
2630
+ return handle;
2348
2631
  })();
2349
2632
  try {
2350
2633
  return await this.serverPromise;
@@ -2355,7 +2638,13 @@ var OpenCodeProcessAdapter = class {
2355
2638
  }
2356
2639
  async ensureOpencodeSession(handle, ourSessionId, repoPath, title) {
2357
2640
  const existing = this.sessionMap.get(ourSessionId);
2358
- if (existing) return existing;
2641
+ if (existing) {
2642
+ logAdapter(
2643
+ `reuse opencodeSession=${existing.slice(0, 8)} session=${ourSessionId.slice(0, 8)} repo=${repoPath}`
2644
+ );
2645
+ return existing;
2646
+ }
2647
+ logAdapter(`create session=${ourSessionId.slice(0, 8)} repo=${repoPath}`);
2359
2648
  const created = await handle.client.session.create({
2360
2649
  body: { title },
2361
2650
  query: { directory: repoPath }
@@ -2365,8 +2654,49 @@ var OpenCodeProcessAdapter = class {
2365
2654
  throw new Error("opencode session.create returned no id");
2366
2655
  }
2367
2656
  this.sessionMap.set(ourSessionId, id);
2657
+ logAdapter(
2658
+ `created opencodeSession=${id.slice(0, 8)} session=${ourSessionId.slice(0, 8)}`
2659
+ );
2368
2660
  return id;
2369
2661
  }
2662
+ /** Send a decision for a paused permission to opencode. Best-effort: a
2663
+ * failure is logged but not thrown — the worst case is the turn hangs
2664
+ * until the grace/abort path tears it down. */
2665
+ async replyToOpencode(client, openCodeSessionId, repoPath, permissionId, response) {
2666
+ try {
2667
+ await client.postSessionIdPermissionsPermissionId({
2668
+ path: { id: openCodeSessionId, permissionID: permissionId },
2669
+ query: { directory: repoPath },
2670
+ body: { response }
2671
+ });
2672
+ logAdapter(
2673
+ `permission reply id=${permissionId.slice(0, 8)} response=${response}`
2674
+ );
2675
+ } catch (err) {
2676
+ logAdapter(
2677
+ `permission reply FAILED id=${permissionId.slice(0, 8)} response=${response} error=${err instanceof Error ? err.message : String(err)}`
2678
+ );
2679
+ }
2680
+ }
2681
+ /** Apply a browser decision to a paused permission. Invoked by the
2682
+ * /agent/permission route on a separate HTTP request from the SSE stream. */
2683
+ async replyPermission(sessionId, permissionId, response) {
2684
+ const turn = this.activeTurns.get(sessionId);
2685
+ if (!turn) return false;
2686
+ const timer = turn.pending.get(permissionId);
2687
+ if (timer) {
2688
+ clearTimeout(timer);
2689
+ turn.pending.delete(permissionId);
2690
+ }
2691
+ await this.replyToOpencode(
2692
+ turn.client,
2693
+ turn.openCodeSessionId,
2694
+ turn.repoPath,
2695
+ permissionId,
2696
+ response
2697
+ );
2698
+ return true;
2699
+ }
2370
2700
  async *streamMessage(request) {
2371
2701
  let handle;
2372
2702
  try {
@@ -2401,6 +2731,14 @@ var OpenCodeProcessAdapter = class {
2401
2731
  );
2402
2732
  let sawAnyPatch = false;
2403
2733
  let lastPatchHash = "";
2734
+ const permissionMode = request.permissionMode ?? "auto";
2735
+ const activeTurn = {
2736
+ client: handle.client,
2737
+ openCodeSessionId,
2738
+ repoPath: request.repoPath,
2739
+ pending: /* @__PURE__ */ new Map()
2740
+ };
2741
+ this.activeTurns.set(request.sessionId, activeTurn);
2404
2742
  let activeBrokerHandle = null;
2405
2743
  let streamScopedBroker = null;
2406
2744
  if (request.broker) {
@@ -2410,16 +2748,16 @@ var OpenCodeProcessAdapter = class {
2410
2748
  }
2411
2749
  });
2412
2750
  const realCallback = callback;
2413
- streamScopedBroker.request = async (payload) => {
2414
- return realCallback(payload);
2751
+ streamScopedBroker.request = async (payload, observer) => {
2752
+ return realCallback(payload, observer);
2415
2753
  };
2416
2754
  activeBrokerHandle = bindActiveBroker(streamScopedBroker);
2417
- console.error(
2418
- `[opencode-adapter] bound active broker token="${activeBrokerHandle.token.slice(0, 12)}\u2026" session=${request.sessionId.slice(0, 8)}`
2755
+ logAdapter(
2756
+ `bound active broker token="${activeBrokerHandle.token.slice(0, 12)}\u2026" session=${request.sessionId.slice(0, 8)}`
2419
2757
  );
2420
2758
  } else {
2421
- console.error(
2422
- `[opencode-adapter] WARNING: request.broker is missing for session=${request.sessionId.slice(0, 8)} \u2014 shim will 401`
2759
+ logAdapter(
2760
+ `WARNING request.broker missing session=${request.sessionId.slice(0, 8)}; shim will 401`
2423
2761
  );
2424
2762
  }
2425
2763
  const eventController = new AbortController();
@@ -2436,6 +2774,9 @@ var OpenCodeProcessAdapter = class {
2436
2774
  signal: eventController.signal
2437
2775
  });
2438
2776
  eventStream = res.stream;
2777
+ logAdapter(
2778
+ `subscribed events session=${request.sessionId.slice(0, 8)} opencodeSession=${openCodeSessionId.slice(0, 8)}`
2779
+ );
2439
2780
  } catch (err) {
2440
2781
  yield {
2441
2782
  type: "error",
@@ -2446,7 +2787,11 @@ var OpenCodeProcessAdapter = class {
2446
2787
  return;
2447
2788
  }
2448
2789
  const promptBody = {
2449
- parts: [{ type: "text", text: request.prompt }]
2790
+ parts: [
2791
+ { type: "text", text: `${EDIT_DIRECTIVE}
2792
+
2793
+ ${request.prompt}` }
2794
+ ]
2450
2795
  };
2451
2796
  if (streamScopedBroker && activeBrokerHandle) {
2452
2797
  promptBody.model = {
@@ -2454,22 +2799,91 @@ var OpenCodeProcessAdapter = class {
2454
2799
  modelID: DIOLOGUE_MODEL_ID
2455
2800
  };
2456
2801
  }
2802
+ logAdapter(
2803
+ `prompt start session=${request.sessionId.slice(0, 8)} opencodeSession=${openCodeSessionId.slice(0, 8)} promptChars=${request.prompt.length} provider=${request.preferredProvider ?? "(default)"} model=${request.preferredModel ?? "(default)"}`
2804
+ );
2457
2805
  const promptPromise = handle.client.session.prompt({
2458
2806
  path: { id: openCodeSessionId },
2459
2807
  query: { directory: request.repoPath },
2460
2808
  body: promptBody
2809
+ }).then((value) => {
2810
+ logAdapter(
2811
+ `prompt accepted session=${request.sessionId.slice(0, 8)} opencodeSession=${openCodeSessionId.slice(0, 8)}`
2812
+ );
2813
+ return value;
2461
2814
  }).catch((err) => {
2462
- return err instanceof Error ? err : new Error(String(err));
2815
+ const error = err instanceof Error ? err : new Error(String(err));
2816
+ logAdapter(
2817
+ `prompt failed session=${request.sessionId.slice(0, 8)} opencodeSession=${openCodeSessionId.slice(0, 8)} error=${error.message}`
2818
+ );
2819
+ return error;
2463
2820
  });
2821
+ let stopConsuming = false;
2822
+ const stopSignal = new Promise((resolve) => {
2823
+ void promptPromise.finally(() => {
2824
+ setTimeout(() => {
2825
+ stopConsuming = true;
2826
+ resolve("stop");
2827
+ }, END_OF_TURN_GRACE_MS);
2828
+ });
2829
+ });
2830
+ let emittedDone = false;
2831
+ const iterator = eventStream[Symbol.asyncIterator]();
2464
2832
  try {
2465
- for await (const item of eventStream) {
2833
+ while (!stopConsuming) {
2466
2834
  if (request.signal?.aborted) break;
2835
+ const nextP = iterator.next();
2836
+ const next = await Promise.race([
2837
+ nextP,
2838
+ stopSignal.then(() => null)
2839
+ ]);
2840
+ if (next === null) {
2841
+ void nextP.catch(() => void 0);
2842
+ break;
2843
+ }
2844
+ if (next.done) break;
2845
+ const rawItem = next.value;
2846
+ const item = "payload" in rawItem && rawItem.payload ? rawItem.payload : rawItem;
2467
2847
  const mapped = mapEvent(item, mapState);
2848
+ if (mapped.events.length > 0 || mapped.done) {
2849
+ logAdapter(
2850
+ `event ${item.type} mapped=${mapped.events.map((event) => event.type).join(",") || "(none)"} done=${mapped.done} session=${request.sessionId.slice(0, 8)}`
2851
+ );
2852
+ }
2468
2853
  for (const event of mapped.events) {
2854
+ if (event.type === "permission_request") {
2855
+ if (permissionMode === "confirm") {
2856
+ const timer = setTimeout(() => {
2857
+ activeTurn.pending.delete(event.permissionId);
2858
+ void this.replyToOpencode(
2859
+ handle.client,
2860
+ openCodeSessionId,
2861
+ request.repoPath,
2862
+ event.permissionId,
2863
+ "reject"
2864
+ );
2865
+ logAdapter(
2866
+ `permission auto-rejected (timeout) id=${event.permissionId.slice(0, 8)} session=${request.sessionId.slice(0, 8)}`
2867
+ );
2868
+ }, PERMISSION_DECISION_TIMEOUT_MS);
2869
+ activeTurn.pending.set(event.permissionId, timer);
2870
+ yield event;
2871
+ } else {
2872
+ void this.replyToOpencode(
2873
+ handle.client,
2874
+ openCodeSessionId,
2875
+ request.repoPath,
2876
+ event.permissionId,
2877
+ "once"
2878
+ );
2879
+ }
2880
+ continue;
2881
+ }
2469
2882
  if (event.type === "diff_proposed" && mapped.patchToFetch) {
2470
2883
  sawAnyPatch = true;
2471
2884
  lastPatchHash = mapped.patchToFetch.hash;
2472
2885
  }
2886
+ if (event.type === "done") emittedDone = true;
2473
2887
  yield event;
2474
2888
  }
2475
2889
  if (mapped.done) {
@@ -2478,31 +2892,54 @@ var OpenCodeProcessAdapter = class {
2478
2892
  }
2479
2893
  } finally {
2480
2894
  eventController.abort();
2895
+ try {
2896
+ await iterator.return?.();
2897
+ } catch {
2898
+ }
2481
2899
  request.signal?.removeEventListener("abort", stopOnAbort);
2900
+ for (const [permId, timer] of activeTurn.pending) {
2901
+ clearTimeout(timer);
2902
+ void this.replyToOpencode(
2903
+ handle.client,
2904
+ openCodeSessionId,
2905
+ request.repoPath,
2906
+ permId,
2907
+ "reject"
2908
+ );
2909
+ }
2910
+ activeTurn.pending.clear();
2911
+ this.activeTurns.delete(request.sessionId);
2482
2912
  activeBrokerHandle?.release();
2483
2913
  streamScopedBroker?.close("turn_ended");
2484
2914
  }
2485
- if (sawAnyPatch) {
2486
- try {
2487
- const diffRes = await handle.client.session.diff({
2488
- path: { id: openCodeSessionId },
2489
- query: { directory: request.repoPath }
2490
- });
2491
- const unified = diffRes.data?.unified ?? "";
2492
- if (unified) {
2493
- yield {
2494
- type: "diff_proposed",
2495
- unified,
2496
- files: parseUnifiedDiffFiles(unified)
2497
- };
2498
- }
2499
- } catch {
2915
+ try {
2916
+ const diffRes = await handle.client.session.diff({
2917
+ path: { id: openCodeSessionId },
2918
+ query: { directory: request.repoPath }
2919
+ });
2920
+ const unified = diffRes.data?.unified ?? "";
2921
+ if (unified) {
2922
+ yield {
2923
+ type: "diff_proposed",
2924
+ unified,
2925
+ files: parseUnifiedDiffFiles(unified)
2926
+ };
2500
2927
  }
2928
+ } catch (err) {
2929
+ logAdapter(
2930
+ `diff fetch failed session=${request.sessionId.slice(0, 8)} error=${err instanceof Error ? err.message : String(err)}`
2931
+ );
2501
2932
  }
2502
2933
  const promptResult = await promptPromise;
2503
2934
  if (promptResult instanceof Error) {
2504
2935
  yield { type: "error", message: promptResult.message, recoverable: true };
2936
+ } else {
2937
+ logAdapter(`turn complete session=${request.sessionId.slice(0, 8)}`);
2938
+ }
2939
+ if (!emittedDone) {
2940
+ yield { type: "done" };
2505
2941
  }
2942
+ void sawAnyPatch;
2506
2943
  void lastPatchHash;
2507
2944
  }
2508
2945
  async close() {
@@ -2579,6 +3016,7 @@ var createApp = (options) => {
2579
3016
  app.use("/agent", createAgentRouter({ state, adapter, brokerRegistry }));
2580
3017
  app.use("/agent/llm-response", createLlmResponseRouter({ brokerRegistry }));
2581
3018
  app.use("/agent/llm-chunk", createLlmChunkRouter({ brokerRegistry }));
3019
+ app.use("/agent/permission", createPermissionRouter({ adapter }));
2582
3020
  app.use("/llm-shim", createLlmShimRouter());
2583
3021
  app.use((req, res) => {
2584
3022
  res.status(404).json({ error: "not_found", method: req.method, path: req.path });