@diologue/local-agent 0.3.0 → 0.6.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
@@ -726,9 +726,9 @@ var getDiff = async (cwd) => {
726
726
  return [trackedDiff, ...untrackedDiffs].filter((part) => part.trim().length > 0).join("\n");
727
727
  };
728
728
  var runGitWithStdin = async (cwd, args, input) => {
729
- const { execFile: execFile2 } = await import("node:child_process");
729
+ const { execFile: execFile3 } = await import("node:child_process");
730
730
  return new Promise((resolve, reject) => {
731
- const child = execFile2(
731
+ const child = execFile3(
732
732
  "git",
733
733
  args,
734
734
  {
@@ -848,6 +848,73 @@ var validateRepoPath = async (raw) => {
848
848
  return resolved;
849
849
  };
850
850
 
851
+ // src/lib/pick-directory.ts
852
+ import { execFile as execFile2 } from "node:child_process";
853
+ import { homedir } from "node:os";
854
+ var PROMPT = "Select a git repository";
855
+ var dialogSpec = (platform) => {
856
+ switch (platform) {
857
+ case "darwin":
858
+ return {
859
+ cmd: "osascript",
860
+ args: [
861
+ "-e",
862
+ `set theFolder to choose folder with prompt "${PROMPT}"`,
863
+ "-e",
864
+ "POSIX path of theFolder"
865
+ ]
866
+ };
867
+ case "linux":
868
+ return {
869
+ cmd: "zenity",
870
+ args: ["--file-selection", "--directory", `--title=${PROMPT}`]
871
+ };
872
+ case "win32":
873
+ return {
874
+ cmd: "powershell",
875
+ args: [
876
+ "-NoProfile",
877
+ "-Command",
878
+ `Add-Type -AssemblyName System.Windows.Forms;$d = New-Object System.Windows.Forms.FolderBrowserDialog;$d.Description = '${PROMPT}';if ($d.ShowDialog() -eq 'OK') { Write-Output $d.SelectedPath }`
879
+ ]
880
+ };
881
+ default:
882
+ return null;
883
+ }
884
+ };
885
+ var run = (cmd, args) => new Promise((resolve, reject) => {
886
+ execFile2(
887
+ cmd,
888
+ args,
889
+ // Folder dialogs can sit open a while; cap it so the request can't hang
890
+ // forever if the user wanders off. Killing it reads as "cancelled".
891
+ { timeout: 12e4, windowsHide: true },
892
+ (err, stdout) => {
893
+ if (err) reject(err);
894
+ else resolve(stdout);
895
+ }
896
+ );
897
+ });
898
+ var pickDirectory = async () => {
899
+ const spec = dialogSpec(process.platform);
900
+ if (!spec) return { ok: false, reason: "unsupported" };
901
+ const attempt = async (cmd, args) => {
902
+ try {
903
+ const out = (await run(cmd, args)).trim();
904
+ return out ? { ok: true, path: out } : { ok: false, reason: "cancelled" };
905
+ } catch (err) {
906
+ const code = err.code;
907
+ if (code === "ENOENT") return { ok: false, reason: "no_gui" };
908
+ return { ok: false, reason: "cancelled" };
909
+ }
910
+ };
911
+ const result = await attempt(spec.cmd, spec.args);
912
+ if (!result.ok && result.reason === "no_gui" && process.platform === "linux") {
913
+ return attempt("kdialog", ["--getexistingdirectory", homedir()]);
914
+ }
915
+ return result;
916
+ };
917
+
851
918
  // src/routes/repo.ts
852
919
  var selectRepoSchema = z.object({
853
920
  path: z.string().min(1)
@@ -917,6 +984,23 @@ var createRepoRouter = (state) => {
917
984
  throw err;
918
985
  }
919
986
  });
987
+ router.post("/browse", async (_req, res) => {
988
+ const result = await pickDirectory();
989
+ if (result.ok) {
990
+ const body = { path: result.path };
991
+ res.json(body);
992
+ return;
993
+ }
994
+ if (result.reason === "cancelled") {
995
+ const body = { cancelled: true };
996
+ res.json(body);
997
+ return;
998
+ }
999
+ res.status(501).json({
1000
+ error: result.reason,
1001
+ message: "No desktop folder dialog is available on this machine. Type the repo path instead."
1002
+ });
1003
+ });
920
1004
  router.get("/status", async (_req, res) => {
921
1005
  const repo = state.getSelectedRepo();
922
1006
  if (!repo) {
@@ -1266,7 +1350,10 @@ var agentMessageBodySchema = z2.object({
1266
1350
  /** Optional user-chosen routing for this turn. When absent the cloud
1267
1351
  * picks the default (CODING_AGENT_DEFAULT_PROVIDER/MODEL). */
1268
1352
  preferredProvider: z2.string().min(1).max(100).optional(),
1269
- preferredModel: z2.string().min(1).max(200).optional()
1353
+ preferredModel: z2.string().min(1).max(200).optional(),
1354
+ /** Tool-gating policy. Absent → "auto" (run everything) so older clients
1355
+ * keep their current behaviour. */
1356
+ permissionMode: z2.enum(["auto", "confirm"]).optional()
1270
1357
  });
1271
1358
  var writeEvent = (res, event) => {
1272
1359
  res.write(`data: ${JSON.stringify(event)}
@@ -1322,7 +1409,8 @@ var createAgentRouter = (deps) => {
1322
1409
  signal: controller.signal,
1323
1410
  broker: (payload, observer) => broker.request(payload, observer),
1324
1411
  preferredProvider: parsed.data.preferredProvider,
1325
- preferredModel: parsed.data.preferredModel
1412
+ preferredModel: parsed.data.preferredModel,
1413
+ permissionMode: parsed.data.permissionMode
1326
1414
  })) {
1327
1415
  if (controller.signal.aborted) {
1328
1416
  logAgentRoute(
@@ -1493,6 +1581,36 @@ var createLlmChunkRouter = (deps) => {
1493
1581
  });
1494
1582
  return router;
1495
1583
  };
1584
+ var permissionDecisionBodySchema = z2.object({
1585
+ sessionId: z2.string().min(1),
1586
+ permissionId: z2.string().min(1),
1587
+ response: z2.enum(["once", "always", "reject"])
1588
+ });
1589
+ var createPermissionRouter = (deps) => {
1590
+ const router = createRouter2();
1591
+ router.post("/", async (req, res) => {
1592
+ const parsed = permissionDecisionBodySchema.safeParse(req.body);
1593
+ if (!parsed.success) {
1594
+ res.status(400).json({ error: "invalid_body", issues: parsed.error.issues });
1595
+ return;
1596
+ }
1597
+ if (!deps.adapter.replyPermission) {
1598
+ res.status(501).json({ error: "permissions_unsupported" });
1599
+ return;
1600
+ }
1601
+ const matched = await deps.adapter.replyPermission(
1602
+ parsed.data.sessionId,
1603
+ parsed.data.permissionId,
1604
+ parsed.data.response
1605
+ );
1606
+ if (!matched) {
1607
+ res.status(404).json({ error: "no_active_permission" });
1608
+ return;
1609
+ }
1610
+ res.json({ ok: true });
1611
+ });
1612
+ return router;
1613
+ };
1496
1614
 
1497
1615
  // src/routes/llm-shim.ts
1498
1616
  import { Router as createRouter3 } from "express";
@@ -2359,13 +2477,30 @@ var MockOpenCodeAdapter = class {
2359
2477
  };
2360
2478
 
2361
2479
  // src/adapters/opencode-event-mapper.ts
2480
+ var MAX_TOOL_OUTPUT_CHARS = 4e3;
2481
+ var truncateOutput = (raw) => {
2482
+ if (!raw) return void 0;
2483
+ if (raw.length <= MAX_TOOL_OUTPUT_CHARS) return raw;
2484
+ const omitted = raw.length - MAX_TOOL_OUTPUT_CHARS;
2485
+ return `${raw.slice(0, MAX_TOOL_OUTPUT_CHARS)}
2486
+ \u2026 [${omitted} more characters truncated]`;
2487
+ };
2362
2488
  var createMapState = (ourSessionId, openCodeSessionId) => ({
2363
2489
  ourSessionId,
2364
2490
  openCodeSessionId,
2365
2491
  startedTools: /* @__PURE__ */ new Set(),
2366
2492
  endedTools: /* @__PURE__ */ new Set(),
2367
- announcedPatches: /* @__PURE__ */ new Set()
2493
+ announcedPatches: /* @__PURE__ */ new Set(),
2494
+ announcedPermissions: /* @__PURE__ */ new Set()
2368
2495
  });
2496
+ var extractPermissionCommand = (metadata) => {
2497
+ if (!metadata) return void 0;
2498
+ for (const key of ["command", "cmd", "url", "pattern", "filePath"]) {
2499
+ const v = metadata[key];
2500
+ if (typeof v === "string" && v) return v;
2501
+ }
2502
+ return void 0;
2503
+ };
2369
2504
  var EMPTY = { events: [], done: false };
2370
2505
  var mapEvent = (item, state) => {
2371
2506
  const sid = item.properties?.sessionID;
@@ -2375,6 +2510,25 @@ var mapEvent = (item, state) => {
2375
2510
  if (item.type === "session.idle" && sid === state.openCodeSessionId) {
2376
2511
  return { events: [{ type: "done" }], done: true };
2377
2512
  }
2513
+ if (item.type === "permission.updated") {
2514
+ const permissionId = item.properties?.id;
2515
+ if (!permissionId || state.announcedPermissions.has(permissionId)) {
2516
+ return EMPTY;
2517
+ }
2518
+ state.announcedPermissions.add(permissionId);
2519
+ return {
2520
+ events: [
2521
+ {
2522
+ type: "permission_request",
2523
+ permissionId,
2524
+ tool: item.properties?.type ?? "tool",
2525
+ title: item.properties?.title,
2526
+ command: extractPermissionCommand(item.properties?.metadata)
2527
+ }
2528
+ ],
2529
+ done: false
2530
+ };
2531
+ }
2378
2532
  if (item.type !== "message.part.updated") {
2379
2533
  return EMPTY;
2380
2534
  }
@@ -2413,7 +2567,10 @@ var mapEvent = (item, state) => {
2413
2567
  type: "tool_call_end",
2414
2568
  toolCallId: callId,
2415
2569
  ok: status === "completed",
2416
- summary: status === "error" ? part.state?.error ?? "Tool failed" : part.state?.title
2570
+ summary: status === "error" ? part.state?.error ?? "Tool failed" : part.state?.title,
2571
+ output: truncateOutput(
2572
+ status === "error" ? part.state?.error : part.state?.output
2573
+ )
2417
2574
  };
2418
2575
  return { events: [event], done: false };
2419
2576
  }
@@ -2472,6 +2629,7 @@ init_engine_locator_npm();
2472
2629
  var DIOLOGUE_PROVIDER_ID = "diologue";
2473
2630
  var DIOLOGUE_MODEL_ID = "diologue-routed";
2474
2631
  var END_OF_TURN_GRACE_MS = 600;
2632
+ var PERMISSION_DECISION_TIMEOUT_MS = 18e4;
2475
2633
  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.";
2476
2634
  var logAdapter = (message) => {
2477
2635
  console.error(`[opencode-adapter] ${message}`);
@@ -2485,6 +2643,9 @@ var OpenCodeProcessAdapter = class {
2485
2643
  /** Maps our sessionId → opencode session id so successive turns within
2486
2644
  * one coding-agent session reuse the same opencode conversation. */
2487
2645
  sessionMap = /* @__PURE__ */ new Map();
2646
+ /** In-flight turns by our sessionId, so replyPermission() can reach the
2647
+ * engine client to resolve a paused tool call. */
2648
+ activeTurns = /* @__PURE__ */ new Map();
2488
2649
  async resolveLocator() {
2489
2650
  if (this.options.engineLocator) {
2490
2651
  return this.options.engineLocator;
@@ -2527,14 +2688,17 @@ var OpenCodeProcessAdapter = class {
2527
2688
  }
2528
2689
  },
2529
2690
  model: `${DIOLOGUE_PROVIDER_ID}/${DIOLOGUE_MODEL_ID}`,
2530
- // Auto-approve tool execution. opencode gates edit/bash/webfetch
2531
- // behind a permission prompt; this helper runs headless with no
2532
- // approver, so without this the agent's writes are never applied to
2533
- // the repo and a turn finishes having changed nothing.
2691
+ // Permission policy. The ADAPTER is the policy engine, not this config:
2692
+ // - edit stays "allow" edits land in the working tree and are
2693
+ // reversible via the chat's Keep/Revert, so we never prompt on them.
2694
+ // - bash + webfetch are "ask" so opencode pauses them. In "auto" mode
2695
+ // the adapter auto-approves instantly (behaves like allow); in
2696
+ // "confirm" mode it forwards an AgentPermissionRequest to the browser
2697
+ // and resumes only once the user replies. See streamMessage().
2534
2698
  permission: {
2535
2699
  edit: "allow",
2536
- bash: "allow",
2537
- webfetch: "allow"
2700
+ bash: "ask",
2701
+ webfetch: "ask"
2538
2702
  }
2539
2703
  };
2540
2704
  }
@@ -2579,6 +2743,44 @@ var OpenCodeProcessAdapter = class {
2579
2743
  );
2580
2744
  return id;
2581
2745
  }
2746
+ /** Send a decision for a paused permission to opencode. Best-effort: a
2747
+ * failure is logged but not thrown — the worst case is the turn hangs
2748
+ * until the grace/abort path tears it down. */
2749
+ async replyToOpencode(client, openCodeSessionId, repoPath, permissionId, response) {
2750
+ try {
2751
+ await client.postSessionIdPermissionsPermissionId({
2752
+ path: { id: openCodeSessionId, permissionID: permissionId },
2753
+ query: { directory: repoPath },
2754
+ body: { response }
2755
+ });
2756
+ logAdapter(
2757
+ `permission reply id=${permissionId.slice(0, 8)} response=${response}`
2758
+ );
2759
+ } catch (err) {
2760
+ logAdapter(
2761
+ `permission reply FAILED id=${permissionId.slice(0, 8)} response=${response} error=${err instanceof Error ? err.message : String(err)}`
2762
+ );
2763
+ }
2764
+ }
2765
+ /** Apply a browser decision to a paused permission. Invoked by the
2766
+ * /agent/permission route on a separate HTTP request from the SSE stream. */
2767
+ async replyPermission(sessionId, permissionId, response) {
2768
+ const turn = this.activeTurns.get(sessionId);
2769
+ if (!turn) return false;
2770
+ const timer = turn.pending.get(permissionId);
2771
+ if (timer) {
2772
+ clearTimeout(timer);
2773
+ turn.pending.delete(permissionId);
2774
+ }
2775
+ await this.replyToOpencode(
2776
+ turn.client,
2777
+ turn.openCodeSessionId,
2778
+ turn.repoPath,
2779
+ permissionId,
2780
+ response
2781
+ );
2782
+ return true;
2783
+ }
2582
2784
  async *streamMessage(request) {
2583
2785
  let handle;
2584
2786
  try {
@@ -2613,6 +2815,14 @@ var OpenCodeProcessAdapter = class {
2613
2815
  );
2614
2816
  let sawAnyPatch = false;
2615
2817
  let lastPatchHash = "";
2818
+ const permissionMode = request.permissionMode ?? "auto";
2819
+ const activeTurn = {
2820
+ client: handle.client,
2821
+ openCodeSessionId,
2822
+ repoPath: request.repoPath,
2823
+ pending: /* @__PURE__ */ new Map()
2824
+ };
2825
+ this.activeTurns.set(request.sessionId, activeTurn);
2616
2826
  let activeBrokerHandle = null;
2617
2827
  let streamScopedBroker = null;
2618
2828
  if (request.broker) {
@@ -2725,6 +2935,34 @@ ${request.prompt}` }
2725
2935
  );
2726
2936
  }
2727
2937
  for (const event of mapped.events) {
2938
+ if (event.type === "permission_request") {
2939
+ if (permissionMode === "confirm") {
2940
+ const timer = setTimeout(() => {
2941
+ activeTurn.pending.delete(event.permissionId);
2942
+ void this.replyToOpencode(
2943
+ handle.client,
2944
+ openCodeSessionId,
2945
+ request.repoPath,
2946
+ event.permissionId,
2947
+ "reject"
2948
+ );
2949
+ logAdapter(
2950
+ `permission auto-rejected (timeout) id=${event.permissionId.slice(0, 8)} session=${request.sessionId.slice(0, 8)}`
2951
+ );
2952
+ }, PERMISSION_DECISION_TIMEOUT_MS);
2953
+ activeTurn.pending.set(event.permissionId, timer);
2954
+ yield event;
2955
+ } else {
2956
+ void this.replyToOpencode(
2957
+ handle.client,
2958
+ openCodeSessionId,
2959
+ request.repoPath,
2960
+ event.permissionId,
2961
+ "once"
2962
+ );
2963
+ }
2964
+ continue;
2965
+ }
2728
2966
  if (event.type === "diff_proposed" && mapped.patchToFetch) {
2729
2967
  sawAnyPatch = true;
2730
2968
  lastPatchHash = mapped.patchToFetch.hash;
@@ -2743,6 +2981,18 @@ ${request.prompt}` }
2743
2981
  } catch {
2744
2982
  }
2745
2983
  request.signal?.removeEventListener("abort", stopOnAbort);
2984
+ for (const [permId, timer] of activeTurn.pending) {
2985
+ clearTimeout(timer);
2986
+ void this.replyToOpencode(
2987
+ handle.client,
2988
+ openCodeSessionId,
2989
+ request.repoPath,
2990
+ permId,
2991
+ "reject"
2992
+ );
2993
+ }
2994
+ activeTurn.pending.clear();
2995
+ this.activeTurns.delete(request.sessionId);
2746
2996
  activeBrokerHandle?.release();
2747
2997
  streamScopedBroker?.close("turn_ended");
2748
2998
  }
@@ -2850,6 +3100,7 @@ var createApp = (options) => {
2850
3100
  app.use("/agent", createAgentRouter({ state, adapter, brokerRegistry }));
2851
3101
  app.use("/agent/llm-response", createLlmResponseRouter({ brokerRegistry }));
2852
3102
  app.use("/agent/llm-chunk", createLlmChunkRouter({ brokerRegistry }));
3103
+ app.use("/agent/permission", createPermissionRouter({ adapter }));
2853
3104
  app.use("/llm-shim", createLlmShimRouter());
2854
3105
  app.use((req, res) => {
2855
3106
  res.status(404).json({ error: "not_found", method: req.method, path: req.path });