jorgex-stack 1.9.73 → 1.9.74

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.
@@ -1,4 +1,5 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
2
3
  import path from "node:path";
3
4
 
4
5
  interface WorktreePluginConfig {
@@ -16,31 +17,21 @@ interface ScriptResult {
16
17
  scriptPath: string;
17
18
  }
18
19
 
19
- const logToOpenCode = async (
20
- client: unknown,
21
- level: "warn" | "error",
22
- message: string,
23
- extra?: unknown,
24
- ) => {
25
- if (typeof client !== "object" || client === null) return;
26
- const app = (client as { app?: unknown }).app;
27
- if (typeof app !== "object" || app === null) return;
28
- const log = (app as { log?: unknown }).log;
29
- if (typeof log !== "function") return;
30
-
31
- const safeExtra =
32
- extra instanceof Error
33
- ? { message: extra.message, stack: extra.stack }
34
- : extra;
20
+ interface RunScriptOptions {
21
+ payload?: unknown;
22
+ commandArgs?: string[];
23
+ activeWorktreePath?: string;
24
+ }
35
25
 
36
- try {
37
- await log.call(app, {
38
- body: { service: "hooks", level, message, extra: safeExtra },
39
- });
40
- } catch {
41
- // Logging must not break the OpenCode TUI hook.
42
- }
43
- };
26
+ interface GitResult {
27
+ stdout: string;
28
+ stderr: string;
29
+ exitCode: number;
30
+ }
31
+
32
+ // El host v2 llama `shell` a la herramienta de shell; `bash` se conserva como
33
+ // alias porque el nombre observado varía entre builds.
34
+ const SHELL_TOOLS = new Set(["shell", "bash"]);
44
35
 
45
36
  const isAbsolutePath = (value: string) =>
46
37
  /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("/");
@@ -57,8 +48,7 @@ const joinProjectPath = (directory: string, target: string) => {
57
48
 
58
49
  const resolvePath = (base: string, target: string) => {
59
50
  const api = isWindowsPath(base) || isWindowsPath(target) ? path.win32 : path.posix;
60
- const resolved = api.resolve(base, target);
61
- return toSlashes(resolved);
51
+ return toSlashes(api.resolve(base, target));
62
52
  };
63
53
 
64
54
  const resolveProjectPath = (directory: string, target?: string) => {
@@ -69,6 +59,11 @@ const resolveProjectPath = (directory: string, target?: string) => {
69
59
  const isPlainObject = (value: unknown): value is Record<string, unknown> =>
70
60
  typeof value === "object" && value !== null && !Array.isArray(value);
71
61
 
62
+ const commandText = (command: unknown): string => {
63
+ if (Array.isArray(command)) return command.map(String).join(" ");
64
+ return typeof command === "string" ? command : "";
65
+ };
66
+
72
67
  const validateConfig = (config: Record<string, unknown>) => {
73
68
  for (const field of ["setupScript", "docsReminderScript", "pathContains"]) {
74
69
  if (field in config && typeof config[field] !== "string") {
@@ -85,19 +80,6 @@ const validateConfig = (config: Record<string, unknown>) => {
85
80
  }
86
81
  };
87
82
 
88
- const getPayloadWorktreePath = (payload: unknown) => {
89
- if (!isPlainObject(payload)) return undefined;
90
-
91
- const topLevel = payload.activeWorktreePath || payload.worktreePath;
92
- if (typeof topLevel === "string" && topLevel) return topLevel;
93
-
94
- const args = payload.args;
95
- if (!isPlainObject(args)) return undefined;
96
-
97
- const argPath = args.activeWorktreePath || args.worktreePath;
98
- return typeof argPath === "string" && argPath ? argPath : undefined;
99
- };
100
-
101
83
  const buildScriptEnv = (scriptPath: string, activeWorktreePath?: string) => {
102
84
  const env = Object.fromEntries(
103
85
  Object.entries(process.env).filter((entry): entry is [string, string] =>
@@ -122,22 +104,13 @@ const buildScriptEnv = (scriptPath: string, activeWorktreePath?: string) => {
122
104
  return env;
123
105
  };
124
106
 
125
- interface RunScriptOptions {
126
- payload?: unknown;
127
- commandArgs?: string[];
128
- activeWorktreePath?: string;
129
- }
130
-
131
107
  const runScript = async (
132
- client: any,
133
108
  directory: string,
134
109
  script: string,
135
110
  options?: RunScriptOptions,
136
111
  ): Promise<ScriptResult> => {
137
112
  const payload = options?.payload;
138
- const scriptPath = isAbsolutePath(script)
139
- ? script
140
- : joinProjectPath(directory, script);
113
+ const scriptPath = isAbsolutePath(script) ? script : joinProjectPath(directory, script);
141
114
  const stdin = payload ? JSON.stringify(payload) : "";
142
115
  const stdinSource = new Response(stdin);
143
116
 
@@ -145,7 +118,6 @@ const runScript = async (
145
118
 
146
119
  // Resolve bash path: Bun's spawned process may not inherit Git Bash in PATH on Windows
147
120
  const resolveBash = (): string => {
148
- const fs = require("fs");
149
121
  const candidates = [
150
122
  "C:/Program Files/Git/usr/bin/bash.exe",
151
123
  "C:/Program Files/Git/bin/bash.exe",
@@ -155,7 +127,7 @@ const runScript = async (
155
127
  ].filter(Boolean) as string[];
156
128
  for (const candidate of candidates) {
157
129
  try {
158
- if (fs.existsSync(candidate)) return candidate;
130
+ if (existsSync(candidate)) return candidate;
159
131
  } catch {
160
132
  /* skip */
161
133
  }
@@ -184,21 +156,20 @@ const runScript = async (
184
156
  }
185
157
 
186
158
  if (!command) {
187
- const stderr = `Unsupported worktree hook script extension for ${scriptPath}.`;
188
- await logToOpenCode(
189
- client,
190
- "warn",
191
- "Unsupported worktree hook script extension",
192
- { script, scriptPath },
193
- );
194
- return { exitCode: 1, stdout: "", stderr, script, scriptPath };
159
+ return {
160
+ exitCode: 1,
161
+ stdout: "",
162
+ stderr: `Unsupported worktree hook script extension for ${scriptPath}.`,
163
+ script,
164
+ scriptPath,
165
+ };
195
166
  }
196
167
 
197
168
  try {
198
- const activeWorktreePath =
199
- options?.activeWorktreePath || getPayloadWorktreePath(payload);
200
- const spawnEnv = buildScriptEnv(scriptPath, activeWorktreePath);
201
-
169
+ const spawnEnv = buildScriptEnv(
170
+ scriptPath,
171
+ options?.activeWorktreePath,
172
+ );
202
173
  const proc = (globalThis as any).Bun.spawn(command, {
203
174
  stdin: stdinSource,
204
175
  stdout: "pipe",
@@ -213,28 +184,8 @@ const runScript = async (
213
184
  proc.exited,
214
185
  ]);
215
186
 
216
- if (exitCode !== 0) {
217
- await logToOpenCode(
218
- client,
219
- "error",
220
- "Worktree hook script execution failed",
221
- {
222
- script,
223
- scriptPath,
224
- exitCode,
225
- stderr: stderr.trim(),
226
- stdout: stdout.trim(),
227
- },
228
- );
229
- }
230
-
231
187
  return { exitCode, stdout, stderr, script, scriptPath };
232
188
  } catch (error) {
233
- await logToOpenCode(client, "error", "Worktree hook script execution failed", {
234
- script,
235
- scriptPath,
236
- error: error instanceof Error ? error.message : String(error),
237
- });
238
189
  return {
239
190
  exitCode: 1,
240
191
  stdout: "",
@@ -260,23 +211,23 @@ const extractScriptMessage = (text: string) => {
260
211
  }
261
212
  };
262
213
 
263
- const appendToolOutput = (output: any, messages: string[]) => {
264
- if (!output || messages.length === 0) return;
214
+ // v2 entrega `result.content` como array readonly. Se reemplaza el objeto
215
+ // entero (nunca se muta el original) conservando output/metadata y anexando el
216
+ // aviso como bloque de texto.
217
+ const appendToResult = (event: any, messages: string[]) => {
218
+ const result = event?.result;
219
+ if (!result || typeof result !== "object" || messages.length === 0) return;
265
220
  const section = messages.join("\n\n");
266
- // Built-in tools (bash, edit, read, write) use output.output (string)
267
- // MCP tools (github_*, supabase_*) use output.content (array of content blocks)
268
- if ("output" in output && typeof output.output === "string") {
269
- output.output = output.output ? `${output.output}\n\n${section}` : section;
270
- } else if ("content" in output && Array.isArray(output.content)) {
271
- output.content.push({ type: "text", text: `\n\n${section}` });
272
- } else if ("content" in output && typeof output.content === "string") {
273
- output.content = output.content
274
- ? `${output.content}\n\n${section}`
275
- : section;
276
- } else {
277
- output.output = section;
278
- output.content = section;
279
- }
221
+ const current = result.content;
222
+ const base = Array.isArray(current)
223
+ ? current
224
+ : typeof current === "string" && current
225
+ ? [{ type: "text", text: current }]
226
+ : [];
227
+ event.result = {
228
+ ...result,
229
+ content: [...base, { type: "text", text: `\n\n${section}` }],
230
+ };
280
231
  };
281
232
 
282
233
  const truncateMessage = (value: string, maxLength = 1200) => {
@@ -284,8 +235,7 @@ const truncateMessage = (value: string, maxLength = 1200) => {
284
235
  return `${value.slice(0, maxLength)}\n...`;
285
236
  };
286
237
 
287
- const normalizePath = (value: string) =>
288
- toSlashes(value).replace(/\/+$/, "");
238
+ const normalizePath = (value: string) => toSlashes(value).replace(/\/+$/, "");
289
239
 
290
240
  const samePath = (left: string, right: string) => {
291
241
  const normalizedLeft = normalizePath(left);
@@ -296,12 +246,6 @@ const samePath = (left: string, right: string) => {
296
246
  return normalizedLeft === normalizedRight;
297
247
  };
298
248
 
299
- const getCommandCwd = (args: Record<string, unknown>, directory: string) => {
300
- const cwd = args.workdir || args.cwd;
301
- if (typeof cwd !== "string" || cwd.length === 0) return directory;
302
- return isAbsolutePath(cwd) ? cwd : resolvePath(directory, cwd);
303
- };
304
-
305
249
  const replaceToken = (value: string, token: string, replacement: string) =>
306
250
  value.split(token).join(replacement);
307
251
 
@@ -316,6 +260,33 @@ const isMissingFileError = (error: unknown) => {
316
260
  );
317
261
  };
318
262
 
263
+ // El cwd del servidor es compartido. La sesión aporta el directorio del
264
+ // proyecto; `input.workdir` (relativo o absoluto) afina el comando concreto.
265
+ const resolveSessionDirectory = async (
266
+ ctx: any,
267
+ sessionID: unknown,
268
+ ): Promise<string | undefined> => {
269
+ if (!ctx?.session || typeof ctx.session.get !== "function") return undefined;
270
+ if (typeof sessionID !== "string" || !sessionID) return undefined;
271
+ try {
272
+ const session = await ctx.session.get({ sessionID });
273
+ const directory = session?.location?.directory;
274
+ return typeof directory === "string" && directory ? directory : undefined;
275
+ } catch {
276
+ return undefined;
277
+ }
278
+ };
279
+
280
+ const resolveDirectories = async (ctx: any, event: any) => {
281
+ const base = await resolveSessionDirectory(ctx, event?.sessionID);
282
+ const workdir = event?.input?.workdir;
283
+ if (typeof workdir === "string" && workdir) {
284
+ if (isAbsolutePath(workdir)) return { base, command: workdir };
285
+ return { base, command: base ? resolvePath(base, workdir) : undefined };
286
+ }
287
+ return { base, command: base };
288
+ };
289
+
319
290
  // Pre/post validation against real Git state. The Bash command is only a
320
291
  // cheap candidate filter; path, branch and detached state come from `git
321
292
  // worktree list --porcelain -z` (NUL-separated fields, NUL-terminated
@@ -409,15 +380,74 @@ const diffNewWorktrees = (
409
380
  post: PorcelainWorktree[],
410
381
  ) => post.filter((postEntry) => !pre.some((preEntry) => samePath(preEntry.path, postEntry.path)));
411
382
 
412
- const getCallID = (input: any, output: any) => {
413
- const value =
414
- input?.callID ?? input?.callId ?? output?.callID ?? output?.callId;
415
- return typeof value === "string" && value ? value : undefined;
383
+ const MAX_BUFFER = 32 * 1024 * 1024;
384
+
385
+ const runGit = (args: string[], cwd: string): Promise<GitResult> =>
386
+ new Promise((resolve) => {
387
+ execFile(
388
+ "git",
389
+ args,
390
+ { cwd, encoding: "utf8", maxBuffer: MAX_BUFFER },
391
+ (error, stdout, stderr) => {
392
+ const exitCode =
393
+ error && typeof (error as { code?: unknown }).code === "number"
394
+ ? (error as { code: number }).code
395
+ : error
396
+ ? 1
397
+ : 0;
398
+ resolve({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode });
399
+ },
400
+ );
401
+ });
402
+
403
+ const gitText = async (args: string[], cwd: string): Promise<string> => {
404
+ const { stdout, stderr, exitCode } = await runGit(args, cwd);
405
+ if (exitCode !== 0) {
406
+ throw new Error(
407
+ stderr.trim() || `git ${args.join(" ")} failed with exit code ${exitCode}`,
408
+ );
409
+ }
410
+ return stdout;
416
411
  };
417
412
 
418
- const getToolArgs = (input: any, output: any): Record<string, unknown> => {
419
- const args = input?.args ?? output?.args;
420
- return isPlainObject(args) ? args : {};
413
+ const readPorcelain = (cwd: string) =>
414
+ gitText(["worktree", "list", "--porcelain", "-z"], cwd);
415
+
416
+ const readCommonDir = async (cwd: string) =>
417
+ toSlashes(
418
+ (await gitText(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd)).trim(),
419
+ );
420
+
421
+ const defaultConfig = (): WorktreePluginConfig => ({
422
+ pathContains: "worktrees/",
423
+ reminderLines: [],
424
+ });
425
+
426
+ const readWorktreeConfig = async (
427
+ directory: string,
428
+ ): Promise<{ config: WorktreePluginConfig; error?: string }> => {
429
+ try {
430
+ const configFile = (globalThis as any).Bun.file(`${directory}/.opencode/worktree.json`);
431
+ if (typeof configFile.exists === "function" && !(await configFile.exists())) {
432
+ return { config: defaultConfig() };
433
+ }
434
+ const parsed = JSON.parse(await configFile.text()) as unknown;
435
+ if (!isPlainObject(parsed)) {
436
+ return { config: defaultConfig(), error: "Worktree config must be a JSON object." };
437
+ }
438
+ const error = validateConfig(parsed);
439
+ if (error) return { config: defaultConfig(), error };
440
+ return { config: { ...defaultConfig(), ...(parsed as WorktreePluginConfig) } };
441
+ } catch (error) {
442
+ if (isMissingFileError(error)) return { config: defaultConfig() };
443
+ const couldNotParse = error instanceof SyntaxError;
444
+ return {
445
+ config: defaultConfig(),
446
+ error: couldNotParse
447
+ ? "Worktree config could not be parsed as JSON."
448
+ : "Worktree config could not be read.",
449
+ };
450
+ }
421
451
  };
422
452
 
423
453
  interface PendingCapturing {
@@ -442,94 +472,52 @@ interface PendingFailedCapture {
442
472
 
443
473
  type PendingCapture = PendingCapturing | PendingReadyCapture | PendingFailedCapture;
444
474
 
445
- export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
446
- let config: WorktreePluginConfig = {
447
- pathContains: "worktrees/",
448
- reminderLines: [],
449
- };
450
- let configError: string | undefined;
451
-
452
- try {
453
- const configPath = `${directory}/.opencode/worktree.json`;
454
- const configFile = (globalThis as any).Bun.file(configPath);
455
- if (
456
- typeof configFile.exists === "function" &&
457
- !(await configFile.exists())
458
- ) {
459
- // Optional project config.
460
- } else {
461
- const parsed = JSON.parse(await configFile.text()) as unknown;
462
- if (!isPlainObject(parsed)) {
463
- configError = "Worktree config must be a JSON object.";
464
- } else {
465
- configError = validateConfig(parsed);
466
- if (!configError) {
467
- config = { ...config, ...(parsed as WorktreePluginConfig) };
468
- }
469
- }
470
- }
471
- } catch (error) {
472
- if (!isMissingFileError(error)) {
473
- const couldNotParse = error instanceof SyntaxError;
474
- configError = couldNotParse
475
- ? "Worktree config could not be parsed as JSON."
476
- : "Worktree config could not be read.";
477
- await logToOpenCode(
478
- client,
479
- "warn",
480
- couldNotParse
481
- ? "Worktree config could not be parsed"
482
- : "Worktree config could not be read",
483
- error,
484
- );
485
- }
486
- }
475
+ const AMBIGUOUS_MESSAGES = (reason: string) => [
476
+ reason,
477
+ "Run `git worktree list --porcelain` to inspect the current inventory.",
478
+ ];
487
479
 
488
- const pending = new Map<string, PendingCapture>();
480
+ export default {
481
+ id: "stack-worktree",
489
482
 
490
- const readCommonDir = async (cwd: string) =>
491
- toSlashes(
492
- String(
493
- await $`git -C ${cwd} rev-parse --path-format=absolute --git-common-dir`.quiet().text(),
494
- ).trim(),
495
- );
483
+ async setup(ctx: any) {
484
+ const pending = new Map<string, PendingCapture>();
496
485
 
497
- const failCapture = async (callID: string, cwd: string, cause: unknown) => {
498
- const message = cause instanceof Error ? cause.message : String(cause);
499
- pending.set(callID, { status: "failed", cwd, error: truncateMessage(message) });
500
- await logToOpenCode(client, "error", "Worktree pre-inventory failed", {
501
- error: message,
502
- });
503
- };
486
+ const failCapture = (id: string, cwd: string, cause: unknown) => {
487
+ const message = cause instanceof Error ? cause.message : String(cause);
488
+ pending.set(id, { status: "failed", cwd, error: truncateMessage(message) });
489
+ };
504
490
 
505
- return {
506
- "tool.execute.before": async (input: any, output: any) => {
491
+ const handleBefore = async (event: any) => {
507
492
  try {
508
- const tool = String(input?.tool || "").toLowerCase();
509
- if (tool !== "bash") return;
510
- const args = getToolArgs(input, output);
511
- const command = args.command;
512
- if (typeof command !== "string" || !isWorktreeAddCommand(command)) return;
513
- const callID = getCallID(input, output);
514
- if (!callID) return;
515
- const commandCwd = getCommandCwd(args, directory);
516
- const provisional: PendingCapturing = { status: "capturing", cwd: commandCwd, ambiguous: false };
517
- for (const [id, cap] of pending) {
518
- if (id !== callID) {
493
+ if (!SHELL_TOOLS.has(String(event?.tool ?? "").toLowerCase())) return;
494
+ if (!isWorktreeAddCommand(commandText(event?.input?.command))) return;
495
+ const id = typeof event?.id === "string" ? event.id : "";
496
+ if (!id) return;
497
+ const { command: commandCwd } = await resolveDirectories(ctx, event);
498
+ if (!commandCwd) return;
499
+
500
+ const provisional: PendingCapturing = {
501
+ status: "capturing",
502
+ cwd: commandCwd,
503
+ ambiguous: false,
504
+ };
505
+ for (const [otherId, cap] of pending) {
506
+ if (otherId !== id) {
519
507
  if (cap.status === "capturing" || cap.status === "ready") cap.ambiguous = true;
520
508
  provisional.ambiguous = true;
521
509
  }
522
510
  }
523
- pending.set(callID, provisional);
511
+ pending.set(id, provisional);
512
+
524
513
  let preRaw: string;
525
514
  try {
526
- preRaw = String(
527
- await $`git -C ${commandCwd} worktree list --porcelain -z`.quiet().text(),
528
- );
515
+ preRaw = await readPorcelain(commandCwd);
529
516
  } catch (error) {
530
- await failCapture(callID, commandCwd, error);
517
+ failCapture(id, commandCwd, error);
531
518
  return;
532
519
  }
520
+
533
521
  let repo: string;
534
522
  try {
535
523
  repo = await readCommonDir(commandCwd);
@@ -537,76 +525,66 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
537
525
  throw new Error(`empty repository identity (common-dir) for ${commandCwd}`);
538
526
  }
539
527
  } catch (error) {
540
- await failCapture(callID, commandCwd, error);
528
+ failCapture(id, commandCwd, error);
541
529
  return;
542
530
  }
543
- pending.set(callID, { status: "ready", pre: preRaw, repo, cwd: commandCwd, ambiguous: provisional.ambiguous });
544
- } catch (error) {
545
- await logToOpenCode(client, "error", "Worktree plugin execution failed", {
546
- error: error instanceof Error ? error.message : String(error),
531
+
532
+ pending.set(id, {
533
+ status: "ready",
534
+ pre: preRaw,
535
+ repo,
536
+ cwd: commandCwd,
537
+ ambiguous: provisional.ambiguous,
547
538
  });
539
+ } catch {
540
+ // fail-open: un fallo del guardia no bloquea la herramienta.
548
541
  }
549
- },
550
- "tool.execute.after": async (input: any, output: any) => {
551
- try {
552
- const tool = ((input.tool || "") as string).toLowerCase();
553
- const args = getToolArgs(input, output);
554
- const command =
555
- typeof args.command === "string" ? (args.command as string) : "";
556
-
557
- const payload = {
558
- event: "tool.execute.after",
559
- directory,
560
- tool: input.tool,
561
- args: input.args || {},
562
- };
542
+ };
563
543
 
564
- if (tool === "enterworktree") {
565
- if (configError) {
566
- appendToolOutput(output, [configError]);
567
- return;
568
- }
544
+ const handleAfter = async (event: any) => {
545
+ try {
546
+ if (!SHELL_TOOLS.has(String(event?.tool ?? "").toLowerCase())) return;
547
+ const command = commandText(event?.input?.command);
548
+ const id = typeof event?.id === "string" ? event.id : "";
569
549
 
570
- const docsReminderScript = resolveProjectPath(
571
- directory,
572
- config.docsReminderScript,
573
- );
574
- if (docsReminderScript) {
575
- const result = await runScript(
576
- client,
577
- directory,
578
- docsReminderScript,
579
- { payload },
580
- );
581
- const messages = [
582
- extractScriptMessage(result.stdout),
583
- extractScriptMessage(result.stderr),
584
- ].filter(Boolean);
585
- appendToolOutput(output, messages);
586
- }
550
+ if (!isWorktreeAddCommand(command)) {
551
+ if (id) pending.delete(id);
587
552
  return;
588
553
  }
589
554
 
590
- if (tool !== "bash") return;
591
-
592
- if (!isWorktreeAddCommand(command)) {
593
- const stale = getCallID(input, output);
594
- if (stale) pending.delete(stale);
555
+ // Un `error` es terminal: la captura de ese ID se retira para no dejar
556
+ // un solapamiento fantasma. El error original no se toca ni se fabrica
557
+ // result. `running`/`background` no son terminales y conservan la
558
+ // captura para el `completed` del mismo ID.
559
+ if (event?.status === "error") {
560
+ if (id) pending.delete(id);
595
561
  return;
596
562
  }
597
563
 
598
- const callID = getCallID(input, output);
599
- const capture = callID ? pending.get(callID) : undefined;
600
- if (!callID || !capture) {
601
- appendToolOutput(output, [
602
- "Worktree creation is ambiguous: missing pre-execution inventory. Skipping setup.",
603
- "Run `git worktree list --porcelain` to inspect the current inventory.",
604
- ]);
564
+ // Sin un result terminal no hay éxito que validar: `running`/`background`
565
+ // (o un error sin result) nunca fabrican setup.
566
+ if (event?.status !== "completed" || !isPlainObject(event.result)) return;
567
+
568
+ const { base, command: commandCwd } = await resolveDirectories(ctx, event);
569
+ const projectDir = base ?? commandCwd;
570
+ const { config, error: configError } = projectDir
571
+ ? await readWorktreeConfig(projectDir)
572
+ : { config: defaultConfig(), error: undefined };
573
+
574
+ const capture = id ? pending.get(id) : undefined;
575
+ if (!capture) {
576
+ appendToResult(
577
+ event,
578
+ AMBIGUOUS_MESSAGES(
579
+ "Worktree creation is ambiguous: missing pre-execution inventory. Skipping setup.",
580
+ ),
581
+ );
605
582
  return;
606
583
  }
584
+
607
585
  try {
608
586
  if (capture.status === "failed") {
609
- appendToolOutput(output, [
587
+ appendToResult(event, [
610
588
  `Worktree pre-inventory capture failed for ${capture.cwd}; skipping worktree setup.`,
611
589
  "Run `git worktree list --porcelain -z` from the project root and retry.",
612
590
  "Run `git rev-parse --show-toplevel` from the project root and retry.",
@@ -616,73 +594,74 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
616
594
  }
617
595
 
618
596
  if (capture.status === "capturing") {
619
- appendToolOutput(output, [
620
- "Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
621
- "Run `git worktree list --porcelain` to inspect the current inventory.",
622
- ]);
597
+ appendToResult(
598
+ event,
599
+ AMBIGUOUS_MESSAGES(
600
+ "Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
601
+ ),
602
+ );
623
603
  return;
624
604
  }
625
605
 
626
606
  let preList: PorcelainWorktree[];
627
607
  try {
628
608
  preList = parsePorcelain(capture.pre);
629
- } catch (error) {
630
- appendToolOutput(output, [
609
+ } catch {
610
+ appendToResult(event, [
631
611
  "Worktree inventory is unreadable; skipping worktree setup.",
632
612
  "Run `git worktree list --porcelain` from the project root and retry.",
633
613
  ]);
634
- await logToOpenCode(client, "error", "Worktree inventory parse failed", {
635
- error: error instanceof Error ? error.message : String(error),
636
- });
637
614
  return;
638
615
  }
639
616
 
640
- const exit = output?.metadata?.exit;
617
+ const exit = event.result?.metadata?.exit;
641
618
  if (typeof exit !== "number" || exit !== 0) {
642
- const ambiguous =
619
+ appendToResult(event, [
643
620
  exit === undefined || exit === null
644
621
  ? "Worktree command result is ambiguous (missing exit code); skipping worktree setup."
645
- : `Worktree command did not succeed (exit ${String(exit)}); skipping worktree setup.`;
646
- appendToolOutput(output, [ambiguous]);
622
+ : `Worktree command did not succeed (exit ${String(exit)}); skipping worktree setup.`,
623
+ ]);
647
624
  return;
648
625
  }
649
626
 
650
627
  if (capture.ambiguous) {
651
- appendToolOutput(output, [
652
- "Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
653
- "Run `git worktree list --porcelain` to inspect the current inventory.",
628
+ appendToResult(
629
+ event,
630
+ AMBIGUOUS_MESSAGES(
631
+ "Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
632
+ ),
633
+ );
634
+ return;
635
+ }
636
+
637
+ if (!commandCwd) {
638
+ appendToResult(event, [
639
+ "Worktree setup omitted: could not resolve the session directory or the command workdir.",
640
+ "The plugin never falls back to its own server cwd. Verify `ctx.session.get` and the command workdir, then retry.",
654
641
  ]);
655
642
  return;
656
643
  }
657
644
 
658
- const commandCwd = getCommandCwd(args, directory);
659
645
  let postRaw: string;
660
646
  let gitRoot: string;
661
647
  let commonCwd: string;
662
648
  let commonDir: string;
663
649
  try {
664
- postRaw = String(
665
- await $`git -C ${commandCwd} worktree list --porcelain -z`.quiet().text(),
650
+ postRaw = await readPorcelain(commandCwd);
651
+ gitRoot = toSlashes(
652
+ (await gitText(["rev-parse", "--show-toplevel"], commandCwd)).trim(),
666
653
  );
667
- gitRoot = String(
668
- await $`git -C ${commandCwd} rev-parse --show-toplevel`.quiet().text(),
669
- )
670
- .trim()
671
- .replace(/\\/g, "/");
672
654
  commonCwd = await readCommonDir(commandCwd);
673
- commonDir = await readCommonDir(directory);
655
+ commonDir = await readCommonDir(projectDir ?? commandCwd);
674
656
  } catch (error) {
675
657
  const details = truncateMessage(
676
658
  error instanceof Error ? error.message : String(error),
677
659
  );
678
- appendToolOutput(output, [
660
+ appendToResult(event, [
679
661
  "Worktree plugin could not process this worktree command.",
680
662
  "Run `git rev-parse --show-toplevel` from the project root and retry.",
681
663
  ...(details ? [`Details: ${details}`] : []),
682
664
  ]);
683
- await logToOpenCode(client, "error", "Worktree plugin execution failed", {
684
- error: error instanceof Error ? error.message : String(error),
685
- });
686
665
  return;
687
666
  }
688
667
 
@@ -690,26 +669,30 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
690
669
  // were in flight and flagged this capture: re-check after every await
691
670
  // and right before attributing effects.
692
671
  if (capture.ambiguous) {
693
- appendToolOutput(output, [
694
- "Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
695
- "Run `git worktree list --porcelain` to inspect the current inventory.",
696
- ]);
672
+ appendToResult(
673
+ event,
674
+ AMBIGUOUS_MESSAGES(
675
+ "Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
676
+ ),
677
+ );
697
678
  return;
698
679
  }
699
680
 
700
681
  if (!samePath(commonCwd, capture.repo)) {
701
- appendToolOutput(output, [
702
- "Worktree creation is ambiguous: repository identity changed between pre- and post-execution inventory. Skipping setup.",
703
- "Run `git worktree list --porcelain` to inspect the current inventory.",
704
- ]);
682
+ appendToResult(
683
+ event,
684
+ AMBIGUOUS_MESSAGES(
685
+ "Worktree creation is ambiguous: repository identity changed between pre- and post-execution inventory. Skipping setup.",
686
+ ),
687
+ );
705
688
  return;
706
689
  }
707
690
 
708
691
  const toplevel = toSlashes(gitRoot).replace(/\/+$/, "");
709
692
  if (!samePath(commonCwd, commonDir)) {
710
- appendToolOutput(output, [
693
+ appendToResult(event, [
711
694
  `Worktree command targets a foreign repository: ${toplevel}. Skipping setup.`,
712
- `The plugin directory belongs to a different repository (${normalizePath(directory)}). Run the command inside the project repository.`,
695
+ `The plugin directory belongs to a different repository (${normalizePath(projectDir ?? commandCwd)}). Run the command inside the project repository.`,
713
696
  ]);
714
697
  return;
715
698
  }
@@ -717,20 +700,17 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
717
700
  let postList: PorcelainWorktree[];
718
701
  try {
719
702
  postList = parsePorcelain(postRaw);
720
- } catch (error) {
721
- appendToolOutput(output, [
703
+ } catch {
704
+ appendToResult(event, [
722
705
  "Worktree inventory is unreadable; skipping worktree setup.",
723
706
  "Run `git worktree list --porcelain` from the project root and retry.",
724
707
  ]);
725
- await logToOpenCode(client, "error", "Worktree inventory parse failed", {
726
- error: error instanceof Error ? error.message : String(error),
727
- });
728
708
  return;
729
709
  }
730
710
 
731
711
  const added = diffNewWorktrees(preList, postList);
732
712
  if (added.length !== 1) {
733
- appendToolOutput(output, [
713
+ appendToResult(event, [
734
714
  `Worktree creation is ambiguous: expected 1 new worktree, found ${added.length}. Skipping setup.`,
735
715
  "Run `git worktree list --porcelain` to inspect the current inventory.",
736
716
  ]);
@@ -740,7 +720,7 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
740
720
  const created = added[0] as PorcelainWorktree;
741
721
  const branchName = created.detached ? undefined : created.branch;
742
722
  if (!branchName) {
743
- appendToolOutput(output, [
723
+ appendToResult(event, [
744
724
  created.detached
745
725
  ? "Worktree is detached; skipping setup. Use a branch worktree under <project-root>/worktrees/<branch>."
746
726
  : "Worktree branch could not be determined from Git; skipping setup.",
@@ -751,20 +731,12 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
751
731
 
752
732
  const absoluteWorktreePath = toSlashes(created.path);
753
733
  const projectRoot = toplevel;
754
- const expectedWorktreePath = resolvePath(
755
- projectRoot,
756
- `worktrees/${branchName}`,
757
- );
758
- const pathContains = toSlashes(
759
- config.pathContains || "worktrees/",
760
- ).toLowerCase();
734
+ const expectedWorktreePath = resolvePath(projectRoot, `worktrees/${branchName}`);
735
+ const pathContains = toSlashes(config.pathContains || "worktrees/").toLowerCase();
761
736
 
762
- const isCanonicalPath = samePath(
763
- absoluteWorktreePath,
764
- expectedWorktreePath,
765
- );
737
+ const isCanonicalPath = samePath(absoluteWorktreePath, expectedWorktreePath);
766
738
  if (!isCanonicalPath) {
767
- appendToolOutput(output, [
739
+ appendToResult(event, [
768
740
  `Worktree path is not canonical: ${absoluteWorktreePath}`,
769
741
  `Use the project-local path instead: ${expectedWorktreePath}`,
770
742
  "Canonical rule: <project-root>/worktrees/<canonical-name> or <project-root>/worktrees/<canonical-name>-prNN.",
@@ -772,23 +744,24 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
772
744
  }
773
745
 
774
746
  if (configError) {
775
- appendToolOutput(output, [configError]);
747
+ appendToResult(event, [configError]);
776
748
  return;
777
749
  }
778
750
 
779
- if (!isCanonicalPath) {
780
- return;
781
- }
751
+ if (!isCanonicalPath) return;
782
752
 
783
753
  if (!normalizePath(absoluteWorktreePath).toLowerCase().includes(pathContains)) {
784
754
  return;
785
755
  }
786
756
 
787
- const setupScript = resolveProjectPath(directory, config.setupScript);
757
+ const setupScript = resolveProjectPath(projectDir ?? commandCwd, config.setupScript);
788
758
  if (setupScript) {
789
- const setupResult = await runScript(client, directory, setupScript, {
759
+ const setupResult = await runScript(projectDir ?? commandCwd, setupScript, {
790
760
  payload: {
791
- ...payload,
761
+ event: "tool.execute.after",
762
+ directory: projectDir ?? commandCwd,
763
+ tool: "bash",
764
+ args: { command },
792
765
  worktreePath: absoluteWorktreePath,
793
766
  worktreeName: branchName,
794
767
  branchName,
@@ -803,14 +776,12 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
803
776
  .filter(Boolean)
804
777
  .join("\n\n"),
805
778
  );
806
- appendToolOutput(output, [
779
+ appendToResult(event, [
807
780
  `Worktree setup failed for ${branchName}.`,
808
781
  setupErrorMessage || "No additional details from setup script.",
809
782
  ]);
810
783
  } else {
811
- appendToolOutput(output, [
812
- `Worktree setup complete: ${branchName}`,
813
- ]);
784
+ appendToResult(event, [`Worktree setup complete: ${branchName}`]);
814
785
  }
815
786
  }
816
787
 
@@ -832,25 +803,25 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
832
803
  ...reminderLines,
833
804
  "--------------------------------------------------",
834
805
  ].join("\n");
835
- appendToolOutput(output, [banner]);
806
+ appendToResult(event, [banner]);
836
807
  }
837
808
  return;
838
809
  } finally {
839
- if (pending.get(callID) === capture) pending.delete(callID);
810
+ if (pending.get(id) === capture) pending.delete(id);
840
811
  }
841
812
  } catch (error) {
842
813
  const details = truncateMessage(
843
814
  error instanceof Error ? error.message : String(error),
844
815
  );
845
- appendToolOutput(output, [
816
+ appendToResult(event, [
846
817
  "Worktree plugin could not process this worktree command.",
847
818
  "Run `git rev-parse --show-toplevel` from the project root and retry.",
848
819
  ...(details ? [`Details: ${details}`] : []),
849
820
  ]);
850
- await logToOpenCode(client, "error", "Worktree plugin execution failed", {
851
- error: error instanceof Error ? error.message : String(error),
852
- });
853
821
  }
854
- },
855
- };
822
+ };
823
+
824
+ await ctx.tool.hook("execute.before", handleBefore);
825
+ await ctx.tool.hook("execute.after", handleAfter);
826
+ },
856
827
  };