@kylecheng3146/agent-ops 0.3.2 → 0.3.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.
@@ -131,7 +131,7 @@ export function buildOpencodePlugin(capabilities, runtimePath) {
131
131
  lines.push(" // opencode initializes plugins once per app, not once per session.", " await runManagedHook($, \"SessionStart\", {", " event: \"SessionStart\",", " projectRoot", " });");
132
132
  }
133
133
  if (events.includes("PreToolUse")) {
134
- lines.push(' hooks["tool.execute.before"] = async (input, output) => {', ' if (input?.tool !== "bash") return;', " await runManagedHook($, \"PreToolUse\", {", " event: \"PreToolUse\",", " projectRoot,", " input,", " output", " });", " };");
134
+ lines.push(' const WRITE_TOOLS = ["bash", "edit", "write", "patch", "apply_patch"];', ' hooks["tool.execute.before"] = async (input, output) => {', ' const tool = typeof input?.tool === "string" ? input.tool.toLowerCase() : "";', " if (!WRITE_TOOLS.includes(tool)) return;", " await runManagedHook($, \"PreToolUse\", {", " event: \"PreToolUse\",", " projectRoot,", " input,", " output", " });", " };");
135
135
  }
136
136
  if (events.includes("Stop")) {
137
137
  lines.push(" hooks.event = async ({ event }) => {", ' if (event?.type !== "session.idle") return;', " await runManagedHook($, \"Stop\", { event: \"Stop\", projectRoot });", " };");
@@ -1,8 +1,54 @@
1
+ import { resolve } from "node:path";
1
2
  import { normalizeHookEvent } from "../../hooks/normalize.js";
2
3
  import { normalizeShellHookEvent } from "../../hooks/shell.js";
4
+ /**
5
+ * opencode tools that write a file without a shell (lowercase tool names).
6
+ * `apply_patch` is the GPT-model replacement for `edit`/`write`; older
7
+ * servers still report `patch`.
8
+ */
9
+ export const OPENCODE_FILE_TOOLS = new Set([
10
+ "edit",
11
+ "write",
12
+ "patch",
13
+ "apply_patch"
14
+ ]);
3
15
  function isRecord(value) {
4
16
  return typeof value === "object" && value !== null && !Array.isArray(value);
5
17
  }
18
+ function sessionIdOf(value) {
19
+ const sessionId = value.sessionID;
20
+ return typeof sessionId === "string" && sessionId.length > 0
21
+ ? sessionId
22
+ : undefined;
23
+ }
24
+ /**
25
+ * Paths embedded in `apply_patch` marker lines, relative to the project root:
26
+ * `*** Add File:`, `*** Update File:`, `*** Move to:`, `*** Delete File:`.
27
+ */
28
+ function patchTextPaths(patchText) {
29
+ const paths = [];
30
+ const marker = /^\*\*\*\s+(?:Add File|Update File|Move to|Delete File):\s*(.+?)\s*$/gmu;
31
+ for (const match of patchText.matchAll(marker)) {
32
+ const target = match[1]?.trim();
33
+ if (target !== undefined && target.length > 0 && !target.includes("\0")) {
34
+ paths.push(target);
35
+ }
36
+ }
37
+ return paths;
38
+ }
39
+ function fileWritePaths(args, projectRoot) {
40
+ const direct = args.filePath ?? args.file_path ?? args.path;
41
+ if (typeof direct === "string" && direct.length > 0 && !direct.includes("\0")) {
42
+ return [resolve(projectRoot, direct)];
43
+ }
44
+ if (typeof args.patchText === "string") {
45
+ const marked = patchTextPaths(args.patchText);
46
+ if (marked.length > 0) {
47
+ return marked.map((target) => resolve(projectRoot, target));
48
+ }
49
+ }
50
+ return null;
51
+ }
6
52
  /**
7
53
  * The managed plugin forwards opencode's own hook payload untouched under
8
54
  * `input`, so every field this adapter reads is documented plugin input rather
@@ -21,7 +67,27 @@ export function normalizeOpencodeHookInput(input) {
21
67
  }
22
68
  if (input.event === "PreToolUse" &&
23
69
  isRecord(input.input) &&
24
- input.input.tool === "bash" &&
70
+ typeof input.input.tool === "string" &&
71
+ OPENCODE_FILE_TOOLS.has(input.input.tool.toLowerCase()) &&
72
+ isRecord(input.output) &&
73
+ isRecord(input.output.args) &&
74
+ typeof projectRoot === "string" &&
75
+ projectRoot.length > 0) {
76
+ const paths = fileWritePaths(input.output.args, projectRoot);
77
+ if (paths !== null) {
78
+ const sessionId = sessionIdOf(input.input);
79
+ return {
80
+ event: "file-write",
81
+ projectRoot,
82
+ paths,
83
+ ...(sessionId === undefined ? {} : { sessionId })
84
+ };
85
+ }
86
+ }
87
+ if (input.event === "PreToolUse" &&
88
+ isRecord(input.input) &&
89
+ typeof input.input.tool === "string" &&
90
+ input.input.tool.toLowerCase() === "bash" &&
25
91
  isRecord(input.output) &&
26
92
  isRecord(input.output.args) &&
27
93
  typeof input.output.args.command === "string" &&
@@ -56,7 +56,7 @@ ensureWorktree) {
56
56
  action: "block",
57
57
  status: "FAIL",
58
58
  code: "WORKTREE_CREATED",
59
- remedy: `worktree.mode is auto, so this session now has its own worktree at ${worktree}. Enter it (Claude Code: EnterWorktree with path ${worktree}) and redo this edit there, at ${join(worktree, relative(mainRoot, target))}.`
59
+ remedy: `worktree.mode is auto, so this session now has its own worktree at ${worktree}. Enter it (Claude Code: EnterWorktree with path ${worktree}; otherwise use the worktree path as every command's working directory) and redo this edit there, at ${join(worktree, relative(mainRoot, target))}.`
60
60
  };
61
61
  }
62
62
  catch (error) {
@@ -68,7 +68,7 @@ ensureWorktree) {
68
68
  action: "block",
69
69
  status: "FAIL",
70
70
  code: "WORKTREE_REQUIRED",
71
- remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly.${failure} Run \`agent-ops worktree add <name> --session ${session}\` in ${mainRoot}, enter the printed path (Claude Code: EnterWorktree with that path), and edit there.`
71
+ remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly.${failure} Run \`agent-ops worktree add <name> --session ${session}\` in ${mainRoot}, enter the printed path (Claude Code: EnterWorktree with that path; otherwise use it as every command's working directory), and edit there.`
72
72
  };
73
73
  }
74
74
  }
@@ -34,6 +34,30 @@ export async function probeReviewTarget(target, options) {
34
34
  ? await mkdtemp(join(tmpdir(), "agent-ops-review-probe-"))
35
35
  : options.cwd;
36
36
  try {
37
+ // agy's sandbox only answers inside a project-shaped directory: an empty
38
+ // temp dir hangs or denies file access, which this probe then misreads as
39
+ // unauthenticated. Cloning first matches the review attempt, which already
40
+ // runs inside a disposable clone; a failed clone falls back to the plain
41
+ // temp dir, which is exactly today's behavior.
42
+ let invocationCwd = directory;
43
+ if (deep && target === "agy") {
44
+ const clone = join(directory, "repository");
45
+ const cloned = await runVerificationCommand({
46
+ id: "review-probe-clone",
47
+ command: "git",
48
+ args: ["clone", "--no-hardlinks", "--quiet", "--", options.cwd, clone],
49
+ cwd: directory,
50
+ required: true,
51
+ evidence: { kind: "exit-code" },
52
+ timeoutMs: Math.min(probeTimeoutMs(options.timeoutMs), 30_000)
53
+ }, {
54
+ cwd: directory,
55
+ ...(options.runner === undefined ? {} : { runner: options.runner })
56
+ });
57
+ if (cloned.status === "PASS") {
58
+ invocationCwd = clone;
59
+ }
60
+ }
37
61
  const invocation = buildProbeInvocation({
38
62
  target,
39
63
  prompt: PROBE_PROMPT,
@@ -48,12 +72,12 @@ export async function probeReviewTarget(target, options) {
48
72
  id: `review-probe-${target}`,
49
73
  command: invocation.command,
50
74
  args: deep ? [...invocation.args] : ["--version"],
51
- cwd: directory,
75
+ cwd: invocationCwd,
52
76
  required: true,
53
77
  evidence: { kind: "exit-code" },
54
78
  timeoutMs: probeTimeoutMs(options.timeoutMs)
55
79
  }, {
56
- cwd: directory,
80
+ cwd: invocationCwd,
57
81
  ...(options.runner === undefined ? {} : { runner: options.runner }),
58
82
  ...(deep
59
83
  ? {
@@ -149,17 +149,18 @@ evidence. Give each editing conversation its own worktree instead:
149
149
 
150
150
  - `mode: "auto"` routes every editing conversation through
151
151
  `agent-ops worktree add <name> --session <id>`, run from the main checkout.
152
- On Claude Code a direct Edit or Write of the main checkout outside
152
+ On Claude Code and opencode a direct Edit or Write of the main checkout outside
153
153
  `.worktrees/` is denied (run `agent-ops update` once so the PreToolUse hook
154
154
  matches the file tools). Absent or `"off"` keeps today's single checkout.
155
155
  - `agent-ops task create` run from the main checkout with a known session
156
156
  creates that session's worktree first (or reuses it) and records the task
157
157
  there, so the work starts in the worktree. Its output names the path to
158
158
  enter and the `--base` to verify against.
159
- - As a fallback on Claude Code, the first denied edit creates that worktree:
159
+ - As a fallback on Claude Code and opencode, the first denied edit creates that worktree:
160
160
  `.worktrees/session-<first eight characters of the session id>`, setup
161
161
  included, bound to the session. The denial names the path to enter with
162
- EnterWorktree; a later denial for the same session reuses it. If creation
162
+ EnterWorktree on Claude Code, or to use as every command's working directory
163
+ otherwise; a later denial for the same session reuses it. If creation
163
164
  fails, the denial says why and falls back to the manual `worktree add`.
164
165
  Run `agent-ops update` once so the PreToolUse hook gets the 600-second
165
166
  timeout setup needs.
@@ -132,16 +132,17 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
132
132
  ```
133
133
 
134
134
  - `mode: "auto"` 讓每個修改中的對話都先在主 checkout 執行
135
- `agent-ops worktree add <name> --session <id>`。Claude Code 直接以 Edit 或
135
+ `agent-ops worktree add <name> --session <id>`。Claude Code 與 opencode 直接以 Edit 或
136
136
  Write 修改主 checkout 中 `.worktrees/` 以外的檔案會被拒絕(需先執行一次
137
137
  `agent-ops update`,讓 PreToolUse hook 比對檔案工具)。未設定或 `"off"` 則維持
138
138
  目前的單一 checkout。
139
139
  - 在主 checkout、已知 session 的情況下執行 `agent-ops task create`,會先建立(或重用)
140
140
  該 session 的 worktree,並把 task 記錄在那裡,讓工作一開始就在 worktree 內。輸出會列出
141
141
  要進入的路徑與 verify 用的 `--base`。
142
- - 作為 Claude Code 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
142
+ - 作為 Claude Code 與 opencode 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
143
143
  `.worktrees/session-<session id 前八個字元>`,包含 setup,並綁定該 session。
144
- 拒絕訊息會列出要以 EnterWorktree 進入的路徑;同一 session 之後被拒絕時會重用它。
144
+ 拒絕訊息會列出要進入的路徑(Claude Code 以 EnterWorktree 進入,其他 harness
145
+ 則作為每條命令的工作目錄);同一 session 之後被拒絕時會重用它。
145
146
  建立失敗時,拒絕訊息會說明原因並退回手動 `worktree add`。需先執行一次
146
147
  `agent-ops update`,讓 PreToolUse hook 取得 setup 所需的 600 秒逾時。
147
148
  - `add` 從主 checkout 的 HEAD 建立 `.worktrees/<name>` 與 branch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",