@ship-cli/opencode 0.0.4 → 0.1.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/src/plugin.ts CHANGED
@@ -1,217 +1,2239 @@
1
1
  /**
2
2
  * Ship OpenCode Plugin
3
3
  *
4
- * Integrates the Ship CLI (Linear task management) with OpenCode.
4
+ * Provides the `ship` tool for Linear task management and stacked changes workflow.
5
+ * Instructions/guidance are handled by the ship-cli skill (.opencode/skill/ship-cli/SKILL.md)
6
+ */
7
+
8
+ import type { Hooks, Plugin, ToolDefinition } from "@opencode-ai/plugin";
9
+ import { tool as createTool } from "@opencode-ai/plugin";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Data from "effect/Data";
12
+ import * as Context from "effect/Context";
13
+ import * as Layer from "effect/Layer";
14
+ import * as Schema from "effect/Schema";
15
+ import * as Option from "effect/Option";
16
+ import { sessionTaskMap, trackTask, getTrackedTask, decodeShipToolArgs } from "./compaction.js";
17
+
18
+ // =============================================================================
19
+ // Types & Errors
20
+ // =============================================================================
21
+
22
+ type BunShell = Parameters<Plugin>[0]["$"];
23
+
24
+ class ShipCommandError extends Data.TaggedError("ShipCommandError")<{
25
+ readonly command: string;
26
+ readonly message: string;
27
+ }> {}
28
+
29
+ class ShipNotConfiguredError extends Data.TaggedError("ShipNotConfiguredError")<{
30
+ readonly reason?: string;
31
+ }> {}
32
+
33
+ class JsonParseError extends Data.TaggedError("JsonParseError")<{
34
+ readonly raw: string;
35
+ readonly cause: unknown;
36
+ }> {}
37
+
38
+ interface ShipStatus {
39
+ configured: boolean;
40
+ teamId?: string;
41
+ teamKey?: string;
42
+ projectId?: string | null;
43
+ }
44
+
45
+ /**
46
+ * Webhook process management - module-level state for persistence across tool calls.
47
+ *
48
+ * Note: We previously attempted using Effect.Ref for state management, but it didn't
49
+ * persist across separate Effect runs (each tool call creates a new runtime/layer).
50
+ * Module-level state is a pragmatic solution for subprocess management.
51
+ *
52
+ * Future consideration: Refactor to use Effect Fibers with Effect.forkDaemon for
53
+ * in-process webhook forwarding, which would allow proper Effect resource management.
54
+ * See BRI-88 for details.
55
+ */
56
+ let cleanupRegistered = false;
57
+ let processToCleanup: ReturnType<typeof Bun.spawn> | null = null;
58
+
59
+ const registerProcessCleanup = (proc: ReturnType<typeof Bun.spawn>) => {
60
+ processToCleanup = proc;
61
+
62
+ if (!cleanupRegistered) {
63
+ cleanupRegistered = true;
64
+
65
+ const cleanup = () => {
66
+ if (processToCleanup && !processToCleanup.killed) {
67
+ processToCleanup.kill();
68
+ processToCleanup = null;
69
+ }
70
+ };
71
+
72
+ process.on("exit", cleanup);
73
+ process.on("SIGINT", () => {
74
+ cleanup();
75
+ process.exit(0);
76
+ });
77
+ process.on("SIGTERM", () => {
78
+ cleanup();
79
+ process.exit(0);
80
+ });
81
+ }
82
+ };
83
+
84
+ const unregisterProcessCleanup = () => {
85
+ processToCleanup = null;
86
+ };
87
+
88
+ interface ShipSubtask {
89
+ id: string;
90
+ identifier: string;
91
+ title: string;
92
+ state: string;
93
+ stateType: string;
94
+ isDone: boolean;
95
+ }
96
+
97
+ // Milestone types
98
+ interface ShipMilestone {
99
+ id: string;
100
+ slug: string;
101
+ name: string;
102
+ description?: string | null;
103
+ targetDate?: string | null;
104
+ projectId: string;
105
+ sortOrder: number;
106
+ }
107
+
108
+ interface ShipTask {
109
+ identifier: string;
110
+ title: string;
111
+ description?: string;
112
+ priority: string;
113
+ status: string;
114
+ state?: string;
115
+ labels: string[];
116
+ url: string;
117
+ branchName?: string;
118
+ subtasks?: ShipSubtask[];
119
+ milestoneId?: string | null;
120
+ milestoneName?: string | null;
121
+ }
122
+
123
+ // Stack types
124
+ interface StackChange {
125
+ changeId: string;
126
+ commitId: string;
127
+ description: string;
128
+ bookmarks: string[];
129
+ isEmpty: boolean;
130
+ isWorkingCopy: boolean;
131
+ }
132
+
133
+ interface StackStatus {
134
+ isRepo: boolean;
135
+ change?: {
136
+ changeId: string;
137
+ commitId: string;
138
+ description: string;
139
+ bookmarks: string[];
140
+ isEmpty: boolean;
141
+ };
142
+ error?: string;
143
+ }
144
+
145
+ interface StackCreateResult {
146
+ created: boolean;
147
+ changeId?: string;
148
+ bookmark?: string;
149
+ workspace?: {
150
+ name: string;
151
+ path: string;
152
+ created: boolean;
153
+ };
154
+ error?: string;
155
+ }
156
+
157
+ // Workspace types
158
+ interface WorkspaceOutput {
159
+ name: string;
160
+ path: string;
161
+ changeId: string;
162
+ description: string;
163
+ isDefault: boolean;
164
+ stackName: string | null;
165
+ taskId: string | null;
166
+ }
167
+
168
+ interface RemoveWorkspaceResult {
169
+ removed: boolean;
170
+ name: string;
171
+ filesDeleted?: boolean;
172
+ error?: string;
173
+ }
174
+
175
+ interface StackDescribeResult {
176
+ updated: boolean;
177
+ changeId?: string;
178
+ description?: string;
179
+ error?: string;
180
+ }
181
+
182
+ interface AbandonedMergedChange {
183
+ changeId: string;
184
+ bookmark?: string;
185
+ }
186
+
187
+ interface StackSyncResult {
188
+ fetched: boolean;
189
+ rebased: boolean;
190
+ trunkChangeId?: string;
191
+ stackSize?: number;
192
+ conflicted?: boolean;
193
+ /** Changes that were auto-abandoned because they were merged */
194
+ abandonedMergedChanges?: AbandonedMergedChange[];
195
+ /** Whether the entire stack was merged and workspace was cleaned up */
196
+ stackFullyMerged?: boolean;
197
+ /** Workspace that was cleaned up (only if stackFullyMerged) */
198
+ cleanedUpWorkspace?: string;
199
+ error?: { tag: string; message: string };
200
+ }
201
+
202
+ interface StackRestackResult {
203
+ restacked: boolean;
204
+ stackSize?: number;
205
+ trunkChangeId?: string;
206
+ conflicted?: boolean;
207
+ error?: string;
208
+ }
209
+
210
+ interface StackSubmitResult {
211
+ pushed: boolean;
212
+ bookmark?: string;
213
+ baseBranch?: string;
214
+ pr?: {
215
+ url: string;
216
+ number: number;
217
+ status: "created" | "updated" | "exists";
218
+ };
219
+ error?: string;
220
+ subscribed?: {
221
+ sessionId: string;
222
+ prNumbers: number[];
223
+ };
224
+ }
225
+
226
+ interface StackSquashResult {
227
+ squashed: boolean;
228
+ intoChangeId?: string;
229
+ description?: string;
230
+ error?: string;
231
+ }
232
+
233
+ interface StackAbandonResult {
234
+ abandoned: boolean;
235
+ changeId?: string;
236
+ newWorkingCopy?: string;
237
+ error?: string;
238
+ }
239
+
240
+ interface StackNavigateResult {
241
+ moved: boolean;
242
+ from?: {
243
+ changeId: string;
244
+ description: string;
245
+ };
246
+ to?: {
247
+ changeId: string;
248
+ description: string;
249
+ };
250
+ error?: string;
251
+ }
252
+
253
+ interface StackUndoResult {
254
+ undone: boolean;
255
+ operation?: string;
256
+ error?: string;
257
+ }
258
+
259
+ interface StackUpdateStaleResult {
260
+ updated: boolean;
261
+ changeId?: string;
262
+ error?: string;
263
+ }
264
+
265
+ interface StackBookmarkResult {
266
+ success: boolean;
267
+ action: "created" | "moved";
268
+ bookmark: string;
269
+ changeId?: string;
270
+ error?: string;
271
+ }
272
+
273
+ interface WebhookStartResult {
274
+ started: boolean;
275
+ pid?: number;
276
+ repo?: string;
277
+ events?: string[];
278
+ error?: string;
279
+ }
280
+
281
+ interface WebhookStopResult {
282
+ stopped: boolean;
283
+ wasRunning: boolean;
284
+ error?: string;
285
+ }
286
+
287
+ interface WebhookSubscribeResult {
288
+ subscribed: boolean;
289
+ sessionId?: string;
290
+ prNumbers?: number[];
291
+ error?: string;
292
+ }
293
+
294
+ interface WebhookUnsubscribeResult {
295
+ unsubscribed: boolean;
296
+ sessionId?: string;
297
+ prNumbers?: number[];
298
+ error?: string;
299
+ }
300
+
301
+ interface WebhookDaemonStatus {
302
+ running: boolean;
303
+ pid?: number;
304
+ repo?: string;
305
+ connectedToGitHub?: boolean;
306
+ subscriptions?: Array<{ sessionId: string; prNumbers: number[] }>;
307
+ uptime?: number;
308
+ }
309
+
310
+ interface WebhookCleanupResult {
311
+ success: boolean;
312
+ removedSessions: string[];
313
+ remainingSessions?: number;
314
+ error?: string;
315
+ }
316
+
317
+ // PR Review types
318
+ // Note: This type mirrors ReviewOutput from CLI's review.ts
319
+ // Kept separate for plugin isolation (plugin doesn't depend on CLI package)
320
+ interface PrReviewOutput {
321
+ prNumber: number;
322
+ prTitle?: string;
323
+ prUrl?: string;
324
+ reviews: Array<{
325
+ id: number;
326
+ author: string;
327
+ state: string;
328
+ body: string;
329
+ submittedAt: string;
330
+ }>;
331
+ codeComments: Array<{
332
+ id: number;
333
+ path: string;
334
+ line: number | null;
335
+ body: string;
336
+ author: string;
337
+ createdAt: string;
338
+ inReplyToId: number | null;
339
+ diffHunk?: string;
340
+ }>;
341
+ conversationComments: Array<{
342
+ id: number;
343
+ body: string;
344
+ author: string;
345
+ createdAt: string;
346
+ }>;
347
+ commentsByFile: Record<
348
+ string,
349
+ Array<{
350
+ line: number | null;
351
+ author: string;
352
+ body: string;
353
+ id: number;
354
+ diffHunk?: string;
355
+ }>
356
+ >;
357
+ error?: string;
358
+ }
359
+
360
+ // =============================================================================
361
+ // Shell Service
362
+ // =============================================================================
363
+
364
+ interface ShellService {
365
+ readonly run: (args: string[], cwd?: string) => Effect.Effect<string, ShipCommandError>;
366
+ }
367
+
368
+ const ShellService = Context.GenericTag<ShellService>("ShellService");
369
+
370
+ /**
371
+ * Create a shell service for running ship commands.
372
+ *
373
+ * @param defaultCwd - Default working directory for commands (from opencode's Instance.directory)
374
+ */
375
+ const makeShellService = (_$: BunShell, defaultCwd?: string): ShellService => {
376
+ const getCommand = (): string[] => {
377
+ if (process.env.NODE_ENV === "development") {
378
+ return ["pnpm", "ship"];
379
+ }
380
+ return ["ship"];
381
+ };
382
+
383
+ /**
384
+ * Schema for validating that a string is valid JSON.
385
+ * Uses Effect's Schema.parseJson() for safe, Effect-native JSON parsing.
386
+ */
387
+ const JsonString = Schema.parseJson();
388
+ const validateJson = Schema.decodeUnknownOption(JsonString);
389
+
390
+ /**
391
+ * Extract JSON from CLI output by finding valid JSON object or array.
392
+ *
393
+ * The CLI may output non-JSON content before the actual JSON response (e.g., spinner
394
+ * output, progress messages). Additionally, task descriptions may contain JSON code
395
+ * blocks which could be incorrectly matched if we search from the start.
396
+ *
397
+ * This function finds all potential JSON start positions and validates each candidate
398
+ * using Schema.parseJson(). We prioritize top-level JSON (no leading whitespace) to
399
+ * avoid matching nested objects inside arrays.
400
+ */
401
+ const extractJson = (output: string): string => {
402
+ // Find all potential JSON start positions (lines starting with { or [)
403
+ // The regex captures leading whitespace to distinguish top-level vs nested JSON
404
+ const matches = [...output.matchAll(/^(\s*)([[{])/gm)];
405
+ if (matches.length === 0) {
406
+ return output;
407
+ }
408
+
409
+ // Separate top-level matches (no leading whitespace) from nested ones
410
+ const topLevelMatches: Array<{ index: number }> = [];
411
+ const nestedMatches: Array<{ index: number }> = [];
412
+
413
+ for (const match of matches) {
414
+ if (match.index === undefined) continue;
415
+ const leadingWhitespace = match[1];
416
+ // Top-level JSON starts at column 0 (no leading whitespace)
417
+ if (leadingWhitespace === "") {
418
+ topLevelMatches.push({ index: match.index });
419
+ } else {
420
+ nestedMatches.push({ index: match.index });
421
+ }
422
+ }
423
+
424
+ // Try top-level matches first (most likely to be the actual response)
425
+ // Then fall back to nested matches if needed
426
+ const orderedMatches = [...topLevelMatches, ...nestedMatches];
427
+
428
+ for (const match of orderedMatches) {
429
+ const candidate = output.slice(match.index).trim();
430
+ // Validate using Schema.parseJson() - returns Option.some if valid
431
+ if (validateJson(candidate)._tag === "Some") {
432
+ return candidate;
433
+ }
434
+ }
435
+
436
+ // Fallback to original output if no valid JSON found
437
+ return output;
438
+ };
439
+
440
+ return {
441
+ run: (args: string[], cwd?: string) =>
442
+ Effect.gen(function* () {
443
+ const cmd = getCommand();
444
+ const fullArgs = [...cmd, ...args];
445
+ const workingDir = cwd ?? defaultCwd;
446
+
447
+ const result = yield* Effect.tryPromise({
448
+ try: async (signal) => {
449
+ const proc = Bun.spawn(fullArgs, {
450
+ stdout: "pipe",
451
+ stderr: "pipe",
452
+ signal,
453
+ cwd: workingDir, // Use provided cwd or default from opencode
454
+ });
455
+
456
+ const [stdout, stderr, exitCode] = await Promise.all([
457
+ new Response(proc.stdout).text(),
458
+ new Response(proc.stderr).text(),
459
+ proc.exited,
460
+ ]);
461
+
462
+ return { exitCode, stdout, stderr };
463
+ },
464
+ catch: (e) =>
465
+ new ShipCommandError({
466
+ command: args.join(" "),
467
+ message: `Failed to execute: ${e}`,
468
+ }),
469
+ });
470
+
471
+ if (result.exitCode !== 0) {
472
+ return yield* new ShipCommandError({
473
+ command: args.join(" "),
474
+ message: result.stderr || result.stdout,
475
+ });
476
+ }
477
+
478
+ if (args.includes("--json")) {
479
+ return extractJson(result.stdout);
480
+ }
481
+
482
+ return result.stdout;
483
+ }),
484
+ };
485
+ };
486
+
487
+ // =============================================================================
488
+ // Ship Service
489
+ // =============================================================================
490
+
491
+ interface ShipService {
492
+ readonly checkConfigured: () => Effect.Effect<ShipStatus, ShipCommandError | JsonParseError>;
493
+ readonly getReadyTasks: () => Effect.Effect<ShipTask[], ShipCommandError | JsonParseError>;
494
+ readonly getBlockedTasks: () => Effect.Effect<ShipTask[], ShipCommandError | JsonParseError>;
495
+ readonly listTasks: (filter?: {
496
+ status?: string;
497
+ priority?: string;
498
+ mine?: boolean;
499
+ }) => Effect.Effect<ShipTask[], ShipCommandError | JsonParseError>;
500
+ readonly getTask: (taskId: string) => Effect.Effect<ShipTask, ShipCommandError | JsonParseError>;
501
+ readonly startTask: (taskId: string, sessionId?: string) => Effect.Effect<void, ShipCommandError>;
502
+ readonly completeTask: (taskId: string) => Effect.Effect<void, ShipCommandError>;
503
+ readonly createTask: (input: {
504
+ title: string;
505
+ description?: string;
506
+ priority?: string;
507
+ parentId?: string;
508
+ }) => Effect.Effect<ShipTask, ShipCommandError | JsonParseError>;
509
+ readonly updateTask: (
510
+ taskId: string,
511
+ input: {
512
+ title?: string;
513
+ description?: string;
514
+ priority?: string;
515
+ status?: string;
516
+ parentId?: string;
517
+ },
518
+ ) => Effect.Effect<ShipTask, ShipCommandError | JsonParseError>;
519
+ readonly addBlocker: (blocker: string, blocked: string) => Effect.Effect<void, ShipCommandError>;
520
+ readonly removeBlocker: (
521
+ blocker: string,
522
+ blocked: string,
523
+ ) => Effect.Effect<void, ShipCommandError>;
524
+ readonly relateTask: (
525
+ taskId: string,
526
+ relatedTaskId: string,
527
+ ) => Effect.Effect<void, ShipCommandError>;
528
+ // Stack operations - all accept optional workdir for workspace support
529
+ readonly getStackLog: (
530
+ workdir?: string,
531
+ ) => Effect.Effect<StackChange[], ShipCommandError | JsonParseError>;
532
+ readonly getStackStatus: (
533
+ workdir?: string,
534
+ ) => Effect.Effect<StackStatus, ShipCommandError | JsonParseError>;
535
+ readonly createStackChange: (input: {
536
+ message?: string;
537
+ bookmark?: string;
538
+ noWorkspace?: boolean;
539
+ taskId?: string;
540
+ workdir?: string;
541
+ }) => Effect.Effect<StackCreateResult, ShipCommandError | JsonParseError>;
542
+ readonly describeStackChange: (
543
+ input: { message?: string; title?: string; description?: string },
544
+ workdir?: string,
545
+ ) => Effect.Effect<StackDescribeResult, ShipCommandError | JsonParseError>;
546
+ readonly syncStack: (
547
+ workdir?: string,
548
+ ) => Effect.Effect<StackSyncResult, ShipCommandError | JsonParseError>;
549
+ readonly restackStack: (
550
+ workdir?: string,
551
+ ) => Effect.Effect<StackRestackResult, ShipCommandError | JsonParseError>;
552
+ readonly submitStack: (input: {
553
+ draft?: boolean;
554
+ title?: string;
555
+ body?: string;
556
+ subscribe?: string; // OpenCode session ID to subscribe to all stack PRs
557
+ workdir?: string;
558
+ }) => Effect.Effect<StackSubmitResult, ShipCommandError | JsonParseError>;
559
+ readonly squashStack: (
560
+ message: string,
561
+ workdir?: string,
562
+ ) => Effect.Effect<StackSquashResult, ShipCommandError | JsonParseError>;
563
+ readonly abandonStack: (
564
+ changeId?: string,
565
+ workdir?: string,
566
+ ) => Effect.Effect<StackAbandonResult, ShipCommandError | JsonParseError>;
567
+ // Stack navigation
568
+ readonly stackUp: (
569
+ workdir?: string,
570
+ ) => Effect.Effect<StackNavigateResult, ShipCommandError | JsonParseError>;
571
+ readonly stackDown: (
572
+ workdir?: string,
573
+ ) => Effect.Effect<StackNavigateResult, ShipCommandError | JsonParseError>;
574
+ // Stack recovery
575
+ readonly stackUndo: (
576
+ workdir?: string,
577
+ ) => Effect.Effect<StackUndoResult, ShipCommandError | JsonParseError>;
578
+ readonly stackUpdateStale: (
579
+ workdir?: string,
580
+ ) => Effect.Effect<StackUpdateStaleResult, ShipCommandError | JsonParseError>;
581
+ // Stack bookmark
582
+ readonly bookmarkStack: (
583
+ name: string,
584
+ move?: boolean,
585
+ workdir?: string,
586
+ ) => Effect.Effect<StackBookmarkResult, ShipCommandError | JsonParseError>;
587
+ // Webhook operations - use Ref for thread-safe process tracking
588
+ readonly startWebhook: (events?: string) => Effect.Effect<WebhookStartResult, never>;
589
+ readonly stopWebhook: () => Effect.Effect<WebhookStopResult, never>;
590
+ readonly getWebhookStatus: () => Effect.Effect<{ running: boolean; pid?: number }, never>;
591
+ // Daemon-based webhook operations
592
+ readonly getDaemonStatus: () => Effect.Effect<
593
+ WebhookDaemonStatus,
594
+ ShipCommandError | JsonParseError
595
+ >;
596
+ readonly subscribeToPRs: (
597
+ sessionId: string,
598
+ prNumbers: number[],
599
+ serverUrl?: string,
600
+ ) => Effect.Effect<WebhookSubscribeResult, ShipCommandError | JsonParseError>;
601
+ readonly unsubscribeFromPRs: (
602
+ sessionId: string,
603
+ prNumbers: number[],
604
+ serverUrl?: string,
605
+ ) => Effect.Effect<WebhookUnsubscribeResult, ShipCommandError | JsonParseError>;
606
+ readonly cleanupStaleSubscriptions: () => Effect.Effect<
607
+ WebhookCleanupResult,
608
+ ShipCommandError | JsonParseError
609
+ >;
610
+ // Workspace operations - accept optional workdir
611
+ readonly listWorkspaces: (
612
+ workdir?: string,
613
+ ) => Effect.Effect<WorkspaceOutput[], ShipCommandError | JsonParseError>;
614
+ readonly removeWorkspace: (
615
+ name: string,
616
+ deleteFiles?: boolean,
617
+ workdir?: string,
618
+ ) => Effect.Effect<RemoveWorkspaceResult, ShipCommandError | JsonParseError>;
619
+ // Milestone operations
620
+ readonly listMilestones: () => Effect.Effect<ShipMilestone[], ShipCommandError | JsonParseError>;
621
+ readonly getMilestone: (
622
+ milestoneId: string,
623
+ ) => Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError>;
624
+ readonly createMilestone: (input: {
625
+ name: string;
626
+ description?: string;
627
+ targetDate?: string;
628
+ }) => Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError>;
629
+ readonly updateMilestone: (
630
+ milestoneId: string,
631
+ input: { name?: string; description?: string; targetDate?: string },
632
+ ) => Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError>;
633
+ readonly deleteMilestone: (milestoneId: string) => Effect.Effect<void, ShipCommandError>;
634
+ readonly setTaskMilestone: (
635
+ taskId: string,
636
+ milestoneId: string,
637
+ ) => Effect.Effect<ShipTask, ShipCommandError | JsonParseError>;
638
+ readonly unsetTaskMilestone: (
639
+ taskId: string,
640
+ ) => Effect.Effect<ShipTask, ShipCommandError | JsonParseError>;
641
+ // PR review operations
642
+ readonly getPrReviews: (
643
+ prNumber?: number,
644
+ unresolved?: boolean,
645
+ workdir?: string,
646
+ ) => Effect.Effect<PrReviewOutput, ShipCommandError | JsonParseError>;
647
+ }
648
+
649
+ const ShipService = Context.GenericTag<ShipService>("ShipService");
650
+
651
+ /**
652
+ * Parse JSON with type assertion.
653
+ *
654
+ * Note: This uses a type assertion rather than Schema.decodeUnknown for simplicity.
655
+ * The CLI is the source of truth for these types, and we trust its JSON output.
656
+ * For a more robust solution, consider importing shared schemas from the CLI package.
5
657
  *
6
- * Features:
7
- * - Context injection via `ship prime` on session start and after compaction
8
- * - Ship tool for task management operations
9
- * - Task agent for autonomous issue completion
658
+ * @param raw - Raw JSON string from CLI output
659
+ * @returns Parsed object with asserted type T
10
660
  */
661
+ const parseJson = <T>(raw: string): Effect.Effect<T, JsonParseError> =>
662
+ Effect.try({
663
+ try: () => {
664
+ const parsed = JSON.parse(raw);
665
+ // Basic runtime validation - ensure we got an object or array
666
+ if (parsed === null || (typeof parsed !== "object" && !Array.isArray(parsed))) {
667
+ throw new Error(`Expected object or array, got ${typeof parsed}`);
668
+ }
669
+ return parsed as T;
670
+ },
671
+ catch: (cause) => new JsonParseError({ raw: raw.slice(0, 500), cause }), // Truncate raw for readability
672
+ });
673
+
674
+ const makeShipService = Effect.gen(function* () {
675
+ const shell = yield* ShellService;
676
+
677
+ const checkConfigured = () =>
678
+ Effect.gen(function* () {
679
+ const output = yield* shell.run(["status", "--json"]);
680
+ return yield* parseJson<ShipStatus>(output);
681
+ });
682
+
683
+ const getReadyTasks = () =>
684
+ Effect.gen(function* () {
685
+ const output = yield* shell.run(["task", "ready", "--json"]);
686
+ return yield* parseJson<ShipTask[]>(output);
687
+ });
688
+
689
+ const getBlockedTasks = () =>
690
+ Effect.gen(function* () {
691
+ const output = yield* shell.run(["task", "blocked", "--json"]);
692
+ return yield* parseJson<ShipTask[]>(output);
693
+ });
694
+
695
+ const listTasks = (filter?: { status?: string; priority?: string; mine?: boolean }) =>
696
+ Effect.gen(function* () {
697
+ const args = ["task", "list", "--json"];
698
+ if (filter?.status) args.push("--status", filter.status);
699
+ if (filter?.priority) args.push("--priority", filter.priority);
700
+ if (filter?.mine) args.push("--mine");
701
+
702
+ const output = yield* shell.run(args);
703
+ return yield* parseJson<ShipTask[]>(output);
704
+ });
705
+
706
+ const getTask = (taskId: string) =>
707
+ Effect.gen(function* () {
708
+ const output = yield* shell.run(["task", "show", "--json", taskId]);
709
+ return yield* parseJson<ShipTask>(output);
710
+ });
711
+
712
+ const startTask = (taskId: string, sessionId?: string) => {
713
+ const args = ["task", "start"];
714
+ if (sessionId) {
715
+ args.push("--session", sessionId);
716
+ }
717
+ args.push(taskId);
718
+ return shell.run(args).pipe(Effect.asVoid);
719
+ };
720
+
721
+ const completeTask = (taskId: string) => shell.run(["task", "done", taskId]).pipe(Effect.asVoid);
722
+
723
+ const createTask = (input: {
724
+ title: string;
725
+ description?: string;
726
+ priority?: string;
727
+ parentId?: string;
728
+ }) =>
729
+ Effect.gen(function* () {
730
+ const args = ["task", "create", "--json"];
731
+ if (input.description) args.push("--description", input.description);
732
+ if (input.priority) args.push("--priority", input.priority);
733
+ if (input.parentId) args.push("--parent", input.parentId);
734
+ args.push(input.title);
735
+
736
+ const output = yield* shell.run(args);
737
+ const response = yield* parseJson<{ task: ShipTask }>(output);
738
+ return response.task;
739
+ });
740
+
741
+ const updateTask = (
742
+ taskId: string,
743
+ input: {
744
+ title?: string;
745
+ description?: string;
746
+ priority?: string;
747
+ status?: string;
748
+ parentId?: string;
749
+ },
750
+ ) =>
751
+ Effect.gen(function* () {
752
+ const args = ["task", "update", "--json"];
753
+ if (input.title) args.push("--title", input.title);
754
+ if (input.description) args.push("--description", input.description);
755
+ if (input.priority) args.push("--priority", input.priority);
756
+ if (input.status) args.push("--status", input.status);
757
+ if (input.parentId !== undefined) args.push("--parent", input.parentId);
758
+ args.push(taskId);
759
+
760
+ const output = yield* shell.run(args);
761
+ const response = yield* parseJson<{ task: ShipTask }>(output);
762
+ return response.task;
763
+ });
764
+
765
+ const addBlocker = (blocker: string, blocked: string) =>
766
+ shell.run(["task", "block", blocker, blocked]).pipe(Effect.asVoid);
767
+
768
+ const removeBlocker = (blocker: string, blocked: string) =>
769
+ shell.run(["task", "unblock", blocker, blocked]).pipe(Effect.asVoid);
770
+
771
+ const relateTask = (taskId: string, relatedTaskId: string) =>
772
+ shell.run(["task", "relate", taskId, relatedTaskId]).pipe(Effect.asVoid);
773
+
774
+ // Stack operations - all accept optional workdir for workspace support
775
+ const getStackLog = (workdir?: string) =>
776
+ Effect.gen(function* () {
777
+ const output = yield* shell.run(["stack", "log", "--json"], workdir);
778
+ return yield* parseJson<StackChange[]>(output);
779
+ });
780
+
781
+ const getStackStatus = (workdir?: string) =>
782
+ Effect.gen(function* () {
783
+ const output = yield* shell.run(["stack", "status", "--json"], workdir);
784
+ return yield* parseJson<StackStatus>(output);
785
+ });
786
+
787
+ const createStackChange = (input: {
788
+ message?: string;
789
+ bookmark?: string;
790
+ noWorkspace?: boolean;
791
+ taskId?: string;
792
+ workdir?: string;
793
+ }) =>
794
+ Effect.gen(function* () {
795
+ const args = ["stack", "create", "--json"];
796
+ if (input.message) args.push("--message", input.message);
797
+ if (input.bookmark) args.push("--bookmark", input.bookmark);
798
+ if (input.noWorkspace) args.push("--no-workspace");
799
+ if (input.taskId) args.push("--task-id", input.taskId);
800
+ const output = yield* shell.run(args, input.workdir);
801
+ return yield* parseJson<StackCreateResult>(output);
802
+ });
803
+
804
+ const describeStackChange = (
805
+ input: { message?: string; title?: string; description?: string },
806
+ workdir?: string,
807
+ ) =>
808
+ Effect.gen(function* () {
809
+ const args = ["stack", "describe", "--json"];
810
+ if (input.message) {
811
+ args.push("--message", input.message);
812
+ } else if (input.title) {
813
+ args.push("--title", input.title);
814
+ if (input.description) {
815
+ args.push("--description", input.description);
816
+ }
817
+ }
818
+ const output = yield* shell.run(args, workdir);
819
+ return yield* parseJson<StackDescribeResult>(output);
820
+ });
821
+
822
+ const syncStack = (workdir?: string) =>
823
+ Effect.gen(function* () {
824
+ const output = yield* shell.run(["stack", "sync", "--json"], workdir);
825
+ return yield* parseJson<StackSyncResult>(output);
826
+ });
827
+
828
+ const restackStack = (workdir?: string) =>
829
+ Effect.gen(function* () {
830
+ const output = yield* shell.run(["stack", "restack", "--json"], workdir);
831
+ return yield* parseJson<StackRestackResult>(output);
832
+ });
833
+
834
+ const submitStack = (input: {
835
+ draft?: boolean;
836
+ title?: string;
837
+ body?: string;
838
+ subscribe?: string;
839
+ workdir?: string;
840
+ }) =>
841
+ Effect.gen(function* () {
842
+ const args = ["stack", "submit", "--json"];
843
+ if (input.draft) args.push("--draft");
844
+ if (input.title) args.push("--title", input.title);
845
+ if (input.body) args.push("--body", input.body);
846
+ if (input.subscribe) args.push("--subscribe", input.subscribe);
847
+ const output = yield* shell.run(args, input.workdir);
848
+ return yield* parseJson<StackSubmitResult>(output);
849
+ });
850
+
851
+ const squashStack = (message: string, workdir?: string) =>
852
+ Effect.gen(function* () {
853
+ const output = yield* shell.run(["stack", "squash", "--json", "-m", message], workdir);
854
+ return yield* parseJson<StackSquashResult>(output);
855
+ });
856
+
857
+ const abandonStack = (changeId?: string, workdir?: string) =>
858
+ Effect.gen(function* () {
859
+ const args = ["stack", "abandon", "--json"];
860
+ if (changeId) args.push(changeId);
861
+ const output = yield* shell.run(args, workdir);
862
+ return yield* parseJson<StackAbandonResult>(output);
863
+ });
864
+
865
+ // Stack navigation
866
+ const stackUp = (workdir?: string) =>
867
+ Effect.gen(function* () {
868
+ const output = yield* shell.run(["stack", "up", "--json"], workdir);
869
+ return yield* parseJson<StackNavigateResult>(output);
870
+ });
871
+
872
+ const stackDown = (workdir?: string) =>
873
+ Effect.gen(function* () {
874
+ const output = yield* shell.run(["stack", "down", "--json"], workdir);
875
+ return yield* parseJson<StackNavigateResult>(output);
876
+ });
877
+
878
+ // Stack recovery
879
+ const stackUndo = (workdir?: string) =>
880
+ Effect.gen(function* () {
881
+ const output = yield* shell.run(["stack", "undo", "--json"], workdir);
882
+ return yield* parseJson<StackUndoResult>(output);
883
+ });
884
+
885
+ const stackUpdateStale = (workdir?: string) =>
886
+ Effect.gen(function* () {
887
+ const output = yield* shell.run(["stack", "update-stale", "--json"], workdir);
888
+ return yield* parseJson<StackUpdateStaleResult>(output);
889
+ });
890
+
891
+ // Stack bookmark
892
+ const bookmarkStack = (name: string, move?: boolean, workdir?: string) =>
893
+ Effect.gen(function* () {
894
+ const args = ["stack", "bookmark", "--json"];
895
+ if (move) args.push("--move");
896
+ args.push(name);
897
+ const output = yield* shell.run(args, workdir);
898
+ return yield* parseJson<StackBookmarkResult>(output);
899
+ });
900
+
901
+ // Webhook operations - uses module-level processToCleanup for persistence across tool calls
902
+
903
+ const startWebhook = (events?: string): Effect.Effect<WebhookStartResult, never> =>
904
+ Effect.gen(function* () {
905
+ // Check if already running using module-level state
906
+ if (processToCleanup && !processToCleanup.killed && processToCleanup.exitCode === null) {
907
+ return {
908
+ started: false,
909
+ error: "Webhook forwarding is already running",
910
+ pid: processToCleanup.pid,
911
+ };
912
+ }
913
+
914
+ // Build command
915
+ const cmd = process.env.NODE_ENV === "development" ? ["pnpm", "ship"] : ["ship"];
916
+ const args = [...cmd, "webhook", "forward"];
917
+ if (events) {
918
+ args.push("--events", events);
919
+ }
920
+
921
+ // Spawn process with stderr for error reporting, ignore stdout to avoid buffer deadlock
922
+ const proc = Bun.spawn(args, {
923
+ stdout: "ignore",
924
+ stderr: "pipe",
925
+ });
926
+
927
+ // Collect stderr output to detect errors
928
+ let stderrOutput = "";
929
+ const stderrReader = (async () => {
930
+ const reader = proc.stderr.getReader();
931
+ const decoder = new TextDecoder();
932
+ try {
933
+ while (true) {
934
+ const { done, value } = await reader.read();
935
+ if (done) break;
936
+ stderrOutput += decoder.decode(value, { stream: true });
937
+ }
938
+ } catch {
939
+ // Ignore read errors
940
+ }
941
+ })();
942
+
943
+ // Wait longer for the process to either:
944
+ // 1. Exit with error (e.g., OpenCode not running)
945
+ // 2. Start successfully and begin forwarding
946
+ yield* Effect.sleep("2 seconds");
947
+
948
+ // Check if process exited (indicates an error)
949
+ if (proc.exitCode !== null || proc.killed) {
950
+ // Wait for stderr to be fully read
951
+ yield* Effect.promise(() => stderrReader);
952
+ return {
953
+ started: false,
954
+ error: stderrOutput.trim() || "Process exited immediately",
955
+ };
956
+ }
957
+
958
+ // Check stderr for known error patterns (process might still be running but failed)
959
+ const errorPatterns = [
960
+ "OpenCode server is not running",
961
+ "No active OpenCode session",
962
+ "not installed",
963
+ "not authenticated",
964
+ "Permission denied",
965
+ ];
966
+
967
+ const hasError = errorPatterns.some((pattern) => stderrOutput.includes(pattern));
968
+ if (hasError) {
969
+ // Kill the process since it's in a bad state
970
+ proc.kill();
971
+ yield* Effect.promise(() => stderrReader);
972
+ return {
973
+ started: false,
974
+ error: stderrOutput.trim(),
975
+ };
976
+ }
977
+
978
+ // Register process for cleanup (uses module-level state)
979
+ registerProcessCleanup(proc);
980
+
981
+ return {
982
+ started: true,
983
+ pid: proc.pid,
984
+ events: events?.split(",").map((e) => e.trim()) || [
985
+ "pull_request",
986
+ "pull_request_review",
987
+ "issue_comment",
988
+ "check_run",
989
+ ],
990
+ };
991
+ });
992
+
993
+ const stopWebhook = (): Effect.Effect<WebhookStopResult, never> =>
994
+ Effect.sync(() => {
995
+ // Use module-level processToCleanup for persistence across Effect runs
996
+ if (!processToCleanup) {
997
+ return {
998
+ stopped: false,
999
+ wasRunning: false,
1000
+ error: "No webhook forwarding process is running",
1001
+ };
1002
+ }
1003
+
1004
+ const wasRunning = !processToCleanup.killed && processToCleanup.exitCode === null;
1005
+ if (wasRunning) {
1006
+ processToCleanup.kill();
1007
+ }
1008
+ unregisterProcessCleanup();
1009
+
1010
+ return {
1011
+ stopped: wasRunning,
1012
+ wasRunning,
1013
+ };
1014
+ });
1015
+
1016
+ const getWebhookStatus = (): Effect.Effect<{ running: boolean; pid?: number }, never> =>
1017
+ Effect.sync(() => {
1018
+ // Use module-level processToCleanup for persistence across Effect runs
1019
+ if (processToCleanup && !processToCleanup.killed && processToCleanup.exitCode === null) {
1020
+ return { running: true, pid: processToCleanup.pid };
1021
+ }
1022
+ return { running: false };
1023
+ });
1024
+
1025
+ // Daemon-based webhook operations - communicate with the webhook daemon via CLI
1026
+
1027
+ const getDaemonStatus = (): Effect.Effect<
1028
+ WebhookDaemonStatus,
1029
+ ShipCommandError | JsonParseError
1030
+ > =>
1031
+ Effect.gen(function* () {
1032
+ // First check if daemon is running by trying to get status
1033
+ const output = yield* shell
1034
+ .run(["webhook", "status", "--json"])
1035
+ .pipe(Effect.catchAll(() => Effect.succeed('{"running":false}')));
1036
+ return yield* parseJson<WebhookDaemonStatus>(output);
1037
+ });
1038
+
1039
+ const subscribeToPRs = (
1040
+ sessionId: string,
1041
+ prNumbers: number[],
1042
+ serverUrl?: string,
1043
+ ): Effect.Effect<WebhookSubscribeResult, ShipCommandError | JsonParseError> =>
1044
+ Effect.gen(function* () {
1045
+ const prNumbersStr = prNumbers.join(",");
1046
+ const args = ["webhook", "subscribe", "--json", "--session", sessionId];
1047
+ if (serverUrl) {
1048
+ args.push("--server-url", serverUrl);
1049
+ }
1050
+ args.push(prNumbersStr);
1051
+ const output = yield* shell.run(args);
1052
+ return yield* parseJson<WebhookSubscribeResult>(output);
1053
+ });
1054
+
1055
+ const unsubscribeFromPRs = (
1056
+ sessionId: string,
1057
+ prNumbers: number[],
1058
+ serverUrl?: string,
1059
+ ): Effect.Effect<WebhookUnsubscribeResult, ShipCommandError | JsonParseError> =>
1060
+ Effect.gen(function* () {
1061
+ const prNumbersStr = prNumbers.join(",");
1062
+ const args = ["webhook", "unsubscribe", "--json", "--session", sessionId];
1063
+ if (serverUrl) {
1064
+ args.push("--server-url", serverUrl);
1065
+ }
1066
+ args.push(prNumbersStr);
1067
+ const output = yield* shell.run(args);
1068
+ return yield* parseJson<WebhookUnsubscribeResult>(output);
1069
+ });
1070
+
1071
+ // Cleanup stale subscriptions
1072
+ const cleanupStaleSubscriptions = (): Effect.Effect<
1073
+ WebhookCleanupResult,
1074
+ ShipCommandError | JsonParseError
1075
+ > =>
1076
+ Effect.gen(function* () {
1077
+ const output = yield* shell.run(["webhook", "cleanup", "--json"]);
1078
+ return yield* parseJson<WebhookCleanupResult>(output);
1079
+ });
1080
+
1081
+ // Workspace operations - accept optional workdir
1082
+ const listWorkspaces = (
1083
+ workdir?: string,
1084
+ ): Effect.Effect<WorkspaceOutput[], ShipCommandError | JsonParseError> =>
1085
+ Effect.gen(function* () {
1086
+ const output = yield* shell.run(["stack", "workspaces", "--json"], workdir);
1087
+ return yield* parseJson<WorkspaceOutput[]>(output);
1088
+ });
1089
+
1090
+ const removeWorkspace = (
1091
+ name: string,
1092
+ deleteFiles?: boolean,
1093
+ workdir?: string,
1094
+ ): Effect.Effect<RemoveWorkspaceResult, ShipCommandError | JsonParseError> =>
1095
+ Effect.gen(function* () {
1096
+ const args = ["stack", "remove-workspace", "--json"];
1097
+ if (deleteFiles) args.push("--delete");
1098
+ args.push(name);
1099
+ const output = yield* shell.run(args, workdir);
1100
+ return yield* parseJson<RemoveWorkspaceResult>(output);
1101
+ });
1102
+
1103
+ // Milestone operations
1104
+ const listMilestones = (): Effect.Effect<ShipMilestone[], ShipCommandError | JsonParseError> =>
1105
+ Effect.gen(function* () {
1106
+ const output = yield* shell.run(["milestone", "list", "--json"]);
1107
+ return yield* parseJson<ShipMilestone[]>(output);
1108
+ });
1109
+
1110
+ const getMilestone = (
1111
+ milestoneId: string,
1112
+ ): Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError> =>
1113
+ Effect.gen(function* () {
1114
+ const output = yield* shell.run(["milestone", "show", "--json", milestoneId]);
1115
+ return yield* parseJson<ShipMilestone>(output);
1116
+ });
1117
+
1118
+ const createMilestone = (input: {
1119
+ name: string;
1120
+ description?: string;
1121
+ targetDate?: string;
1122
+ }): Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError> =>
1123
+ Effect.gen(function* () {
1124
+ const args = ["milestone", "create", "--json"];
1125
+ if (input.description) args.push("--description", input.description);
1126
+ if (input.targetDate) args.push("--target-date", input.targetDate);
1127
+ args.push(input.name);
1128
+
1129
+ const output = yield* shell.run(args);
1130
+ const response = yield* parseJson<{ milestone: ShipMilestone }>(output);
1131
+ return response.milestone;
1132
+ });
1133
+
1134
+ const updateMilestone = (
1135
+ milestoneId: string,
1136
+ input: { name?: string; description?: string; targetDate?: string },
1137
+ ): Effect.Effect<ShipMilestone, ShipCommandError | JsonParseError> =>
1138
+ Effect.gen(function* () {
1139
+ const args = ["milestone", "update", "--json"];
1140
+ if (input.name) args.push("--name", input.name);
1141
+ if (input.description) args.push("--description", input.description);
1142
+ if (input.targetDate) args.push("--target-date", input.targetDate);
1143
+ args.push(milestoneId);
1144
+
1145
+ const output = yield* shell.run(args);
1146
+ const response = yield* parseJson<{ milestone: ShipMilestone }>(output);
1147
+ return response.milestone;
1148
+ });
1149
+
1150
+ const deleteMilestone = (milestoneId: string): Effect.Effect<void, ShipCommandError> =>
1151
+ shell.run(["milestone", "delete", milestoneId]).pipe(Effect.asVoid);
1152
+
1153
+ const setTaskMilestone = (
1154
+ taskId: string,
1155
+ milestoneId: string,
1156
+ ): Effect.Effect<ShipTask, ShipCommandError | JsonParseError> =>
1157
+ Effect.gen(function* () {
1158
+ const args = ["task", "update", "--json", "--milestone", milestoneId, taskId];
1159
+ const output = yield* shell.run(args);
1160
+ const response = yield* parseJson<{ task: ShipTask }>(output);
1161
+ return response.task;
1162
+ });
1163
+
1164
+ const unsetTaskMilestone = (
1165
+ taskId: string,
1166
+ ): Effect.Effect<ShipTask, ShipCommandError | JsonParseError> =>
1167
+ Effect.gen(function* () {
1168
+ // Empty string removes milestone
1169
+ const args = ["task", "update", "--json", "--milestone", "", taskId];
1170
+ const output = yield* shell.run(args);
1171
+ const response = yield* parseJson<{ task: ShipTask }>(output);
1172
+ return response.task;
1173
+ });
1174
+
1175
+ // PR reviews operations
1176
+ const getPrReviews = (
1177
+ prNumber?: number,
1178
+ unresolved?: boolean,
1179
+ workdir?: string,
1180
+ ): Effect.Effect<PrReviewOutput, ShipCommandError | JsonParseError> =>
1181
+ Effect.gen(function* () {
1182
+ const args = ["pr", "reviews", "--json"];
1183
+ if (unresolved) args.push("--unresolved");
1184
+ if (prNumber !== undefined) args.push(String(prNumber));
1185
+ const output = yield* shell.run(args, workdir);
1186
+ return yield* parseJson<PrReviewOutput>(output);
1187
+ });
1188
+
1189
+ return {
1190
+ checkConfigured,
1191
+ getReadyTasks,
1192
+ getBlockedTasks,
1193
+ listTasks,
1194
+ getTask,
1195
+ startTask,
1196
+ completeTask,
1197
+ createTask,
1198
+ updateTask,
1199
+ addBlocker,
1200
+ removeBlocker,
1201
+ relateTask,
1202
+ getStackLog,
1203
+ getStackStatus,
1204
+ createStackChange,
1205
+ describeStackChange,
1206
+ syncStack,
1207
+ restackStack,
1208
+ submitStack,
1209
+ squashStack,
1210
+ abandonStack,
1211
+ stackUp,
1212
+ stackDown,
1213
+ stackUndo,
1214
+ stackUpdateStale,
1215
+ bookmarkStack,
1216
+ startWebhook,
1217
+ stopWebhook,
1218
+ getWebhookStatus,
1219
+ getDaemonStatus,
1220
+ subscribeToPRs,
1221
+ unsubscribeFromPRs,
1222
+ cleanupStaleSubscriptions,
1223
+ listWorkspaces,
1224
+ removeWorkspace,
1225
+ listMilestones,
1226
+ getMilestone,
1227
+ createMilestone,
1228
+ updateMilestone,
1229
+ deleteMilestone,
1230
+ setTaskMilestone,
1231
+ unsetTaskMilestone,
1232
+ getPrReviews,
1233
+ } satisfies ShipService;
1234
+ });
1235
+
1236
+ // =============================================================================
1237
+ // Formatters
1238
+ // =============================================================================
1239
+
1240
+ const formatTaskList = (tasks: ShipTask[]): string =>
1241
+ tasks
1242
+ .map((t) => {
1243
+ const priority = t.priority === "urgent" ? "[!]" : t.priority === "high" ? "[^]" : " ";
1244
+ return `${priority} ${t.identifier.padEnd(10)} ${(t.state || t.status).padEnd(12)} ${t.title}`;
1245
+ })
1246
+ .join("\n");
1247
+
1248
+ const formatTaskDetails = (task: ShipTask): string => {
1249
+ let output = `# ${task.identifier}: ${task.title}
1250
+
1251
+ **Status:** ${task.state || task.status}
1252
+ **Priority:** ${task.priority}
1253
+ **Labels:** ${task.labels.length > 0 ? task.labels.join(", ") : "none"}
1254
+ **URL:** ${task.url}`;
1255
+
1256
+ if (task.branchName) {
1257
+ output += `\n**Branch:** ${task.branchName}`;
1258
+ }
1259
+
1260
+ if (task.description) {
1261
+ output += `\n\n## Description\n\n${task.description}`;
1262
+ }
1263
+
1264
+ if (task.subtasks && task.subtasks.length > 0) {
1265
+ output += `\n\n## Subtasks\n`;
1266
+ for (const subtask of task.subtasks) {
1267
+ const statusIndicator = subtask.isDone ? "[x]" : "[ ]";
1268
+ output += `\n${statusIndicator} ${subtask.identifier}: ${subtask.title} (${subtask.state})`;
1269
+ }
1270
+ }
1271
+
1272
+ return output;
1273
+ };
1274
+
1275
+ // =============================================================================
1276
+ // Tool Actions
1277
+ // =============================================================================
1278
+
1279
+ type ToolArgs = {
1280
+ action: string;
1281
+ taskId?: string;
1282
+ title?: string;
1283
+ description?: string;
1284
+ priority?: string;
1285
+ status?: string;
1286
+ blocker?: string;
1287
+ blocked?: string;
1288
+ relatedTaskId?: string;
1289
+ parentId?: string; // For creating subtasks
1290
+ filter?: {
1291
+ status?: string;
1292
+ priority?: string;
1293
+ mine?: boolean;
1294
+ };
1295
+ // Stack-specific args
1296
+ message?: string;
1297
+ bookmark?: string;
1298
+ draft?: boolean;
1299
+ body?: string;
1300
+ changeId?: string;
1301
+ move?: boolean; // For stack-bookmark to move instead of create
1302
+ // Workspace-specific args
1303
+ noWorkspace?: boolean; // For stack-create to skip workspace creation
1304
+ name?: string; // For remove-workspace (workspace name)
1305
+ deleteFiles?: boolean; // For remove-workspace
1306
+ workdir?: string; // Working directory for VCS operations (for jj workspaces)
1307
+ // Webhook-specific args
1308
+ events?: string;
1309
+ // Daemon webhook subscription args
1310
+ sessionId?: string;
1311
+ prNumbers?: number[];
1312
+ // PR review args
1313
+ prNumber?: number;
1314
+ unresolved?: boolean;
1315
+ // Milestone-specific args
1316
+ milestoneId?: string;
1317
+ milestoneName?: string;
1318
+ milestoneDescription?: string;
1319
+ milestoneTargetDate?: string;
1320
+ };
1321
+
1322
+ /**
1323
+ * Context passed to action handlers for generating guidance.
1324
+ */
1325
+ interface ActionContext {
1326
+ /** OpenCode session ID for webhook subscriptions */
1327
+ sessionId?: string;
1328
+ /** Main repository path (default workspace location) */
1329
+ mainRepoPath: string;
1330
+ /** OpenCode server URL for webhook routing (e.g., http://127.0.0.1:4097) */
1331
+ serverUrl?: string;
1332
+ }
1333
+
1334
+ /**
1335
+ * Options for the addGuidance helper function.
1336
+ */
1337
+ interface GuidanceOptions {
1338
+ /** Explicit working directory path (shown when workspace changes) */
1339
+ workdir?: string;
1340
+ /** Whether to show skill reminder */
1341
+ skill?: boolean;
1342
+ /** Contextual note/message */
1343
+ note?: string;
1344
+ }
1345
+
1346
+ /**
1347
+ * Helper function to format guidance blocks consistently.
1348
+ * Reduces repetition and ensures consistent format across all actions.
1349
+ *
1350
+ * @param next - Suggested next actions (e.g., "action=done | action=ready")
1351
+ * @param opts - Optional workdir, skill reminder, and note
1352
+ * @returns Formatted guidance string to append to command output
1353
+ */
1354
+ const addGuidance = (next: string, opts?: GuidanceOptions): string => {
1355
+ let g = `\n---\nNext: ${next}`;
1356
+ if (opts?.workdir) g += `\nWorkdir: ${opts.workdir}`;
1357
+ if (opts?.skill) g += `\nIMPORTANT: Load skill first → skill(name="ship-cli")`;
1358
+ if (opts?.note) g += `\nNote: ${opts.note}`;
1359
+ return g;
1360
+ };
1361
+
1362
+ /**
1363
+ * Action handlers for the ship tool.
1364
+ * Each handler returns an Effect that produces a formatted string result.
1365
+ *
1366
+ * Using a record of handlers provides:
1367
+ * - Cleaner separation of action logic
1368
+ * - No fall-through bugs
1369
+ * - Easier to add new actions
1370
+ * - Each handler is self-contained
1371
+ */
1372
+ type ActionHandler = (
1373
+ ship: ShipService,
1374
+ args: ToolArgs,
1375
+ context: ActionContext,
1376
+ ) => Effect.Effect<string, ShipCommandError | JsonParseError, never>;
1377
+
1378
+ const actionHandlers: Record<string, ActionHandler> = {
1379
+ status: (ship, _args, _ctx) =>
1380
+ Effect.gen(function* () {
1381
+ const status = yield* ship.checkConfigured();
1382
+ if (status.configured) {
1383
+ const guidance = addGuidance("action=ready (find tasks to work on)", { skill: true });
1384
+ return `Ship is configured.\n\nTeam: ${status.teamKey}\nProject: ${status.projectId || "none"}${guidance}`;
1385
+ }
1386
+ return "Ship is not configured. Run 'ship init' first.";
1387
+ }),
1388
+
1389
+ ready: (ship, _args, _ctx) =>
1390
+ Effect.gen(function* () {
1391
+ const tasks = yield* ship.getReadyTasks();
1392
+ if (tasks.length === 0) {
1393
+ const guidance = addGuidance(
1394
+ "action=blocked (check blocked tasks) | action=create (create a new task)",
1395
+ { skill: true },
1396
+ );
1397
+ return `No tasks ready to work on (all tasks are either blocked or completed).${guidance}`;
1398
+ }
1399
+ const guidance = addGuidance("action=start (begin working on a task)", { skill: true });
1400
+ return `Ready tasks (no blockers):\n\n${formatTaskList(tasks)}${guidance}`;
1401
+ }),
1402
+
1403
+ blocked: (ship, _args, _ctx) =>
1404
+ Effect.gen(function* () {
1405
+ const tasks = yield* ship.getBlockedTasks();
1406
+ if (tasks.length === 0) {
1407
+ const guidance = addGuidance("action=ready (check ready tasks)");
1408
+ return `No blocked tasks.${guidance}`;
1409
+ }
1410
+ const guidance = addGuidance(
1411
+ "action=show (view task details) | action=unblock (remove blocker)",
1412
+ );
1413
+ return `Blocked tasks:\n\n${formatTaskList(tasks)}${guidance}`;
1414
+ }),
1415
+
1416
+ list: (ship, args, _ctx) =>
1417
+ Effect.gen(function* () {
1418
+ const tasks = yield* ship.listTasks(args.filter);
1419
+ if (tasks.length === 0) {
1420
+ const guidance = addGuidance("action=create (create a new task)");
1421
+ return `No tasks found matching the filter.${guidance}`;
1422
+ }
1423
+ const guidance = addGuidance(
1424
+ "action=show (view task details) | action=start (begin working)",
1425
+ );
1426
+ return `Tasks:\n\n${formatTaskList(tasks)}${guidance}`;
1427
+ }),
1428
+
1429
+ show: (ship, args, _ctx) =>
1430
+ Effect.gen(function* () {
1431
+ if (!args.taskId) {
1432
+ return "Error: taskId is required for show action";
1433
+ }
1434
+ const task = yield* ship.getTask(args.taskId);
1435
+ const guidance = addGuidance("action=start (begin work) | action=update (modify task)");
1436
+ return formatTaskDetails(task) + guidance;
1437
+ }),
1438
+
1439
+ start: (ship, args, ctx) =>
1440
+ Effect.gen(function* () {
1441
+ if (!args.taskId) {
1442
+ return "Error: taskId is required for start action";
1443
+ }
1444
+ yield* ship.startTask(args.taskId, ctx.sessionId);
1445
+ const sessionInfo = ctx.sessionId ? ` (labeled with session:${ctx.sessionId})` : "";
1446
+ const guidance = addGuidance(
1447
+ `action=stack-create with taskId="${args.taskId}" (creates isolated workspace for changes)`,
1448
+ {
1449
+ skill: true,
1450
+ note: "IMPORTANT: Create workspace before making any file changes",
1451
+ },
1452
+ );
1453
+ return `Task ${args.taskId} is now in progress${sessionInfo}.\n\nNext step: Create a workspace to isolate your changes.${guidance}`;
1454
+ }),
1455
+
1456
+ done: (ship, args, _ctx) =>
1457
+ Effect.gen(function* () {
1458
+ if (!args.taskId) {
1459
+ return "Error: taskId is required for done action";
1460
+ }
1461
+ yield* ship.completeTask(args.taskId);
1462
+ const guidance = addGuidance(
1463
+ "action=ready (find next task) | action=stack-sync (cleanup if in workspace)",
1464
+ );
1465
+ return `Completed ${args.taskId}${guidance}`;
1466
+ }),
1467
+
1468
+ create: (ship, args, _ctx) =>
1469
+ Effect.gen(function* () {
1470
+ if (!args.title) {
1471
+ return "Error: title is required for create action";
1472
+ }
1473
+ const task = yield* ship.createTask({
1474
+ title: args.title,
1475
+ description: args.description,
1476
+ priority: args.priority,
1477
+ parentId: args.parentId,
1478
+ });
1479
+ const guidance = addGuidance("action=start (begin work) | action=block (add dependencies)");
1480
+ if (args.parentId) {
1481
+ return `Created subtask ${task.identifier}: ${task.title}\nParent: ${args.parentId}\nURL: ${task.url}${guidance}`;
1482
+ }
1483
+ return `Created task ${task.identifier}: ${task.title}\nURL: ${task.url}${guidance}`;
1484
+ }),
1485
+
1486
+ update: (ship, args, _ctx) =>
1487
+ Effect.gen(function* () {
1488
+ if (!args.taskId) {
1489
+ return "Error: taskId is required for update action";
1490
+ }
1491
+ if (
1492
+ !args.title &&
1493
+ !args.description &&
1494
+ !args.priority &&
1495
+ !args.status &&
1496
+ args.parentId === undefined
1497
+ ) {
1498
+ return "Error: at least one of title, description, priority, status, or parentId is required for update";
1499
+ }
1500
+ const task = yield* ship.updateTask(args.taskId, {
1501
+ title: args.title,
1502
+ description: args.description,
1503
+ priority: args.priority,
1504
+ status: args.status,
1505
+ parentId: args.parentId,
1506
+ });
1507
+ let output = `Updated task ${task.identifier}: ${task.title}`;
1508
+ if (args.parentId !== undefined) {
1509
+ output += args.parentId === "" ? "\nParent: removed" : `\nParent: ${args.parentId}`;
1510
+ }
1511
+ output += `\nURL: ${task.url}`;
1512
+ const guidance = addGuidance("action=show (verify changes)");
1513
+ return output + guidance;
1514
+ }),
1515
+
1516
+ block: (ship, args, _ctx) =>
1517
+ Effect.gen(function* () {
1518
+ if (!args.blocker || !args.blocked) {
1519
+ return "Error: both blocker and blocked task IDs are required";
1520
+ }
1521
+ yield* ship.addBlocker(args.blocker, args.blocked);
1522
+ const guidance = addGuidance(
1523
+ "action=ready (find unblocked tasks) | action=blocked (view blocked tasks)",
1524
+ );
1525
+ return `${args.blocker} now blocks ${args.blocked}${guidance}`;
1526
+ }),
1527
+
1528
+ unblock: (ship, args, _ctx) =>
1529
+ Effect.gen(function* () {
1530
+ if (!args.blocker || !args.blocked) {
1531
+ return "Error: both blocker and blocked task IDs are required";
1532
+ }
1533
+ yield* ship.removeBlocker(args.blocker, args.blocked);
1534
+ const guidance = addGuidance("action=ready (find unblocked tasks)");
1535
+ return `Removed ${args.blocker} as blocker of ${args.blocked}${guidance}`;
1536
+ }),
1537
+
1538
+ relate: (ship, args, _ctx) =>
1539
+ Effect.gen(function* () {
1540
+ if (!args.taskId || !args.relatedTaskId) {
1541
+ return "Error: both taskId and relatedTaskId are required for relate action";
1542
+ }
1543
+ yield* ship.relateTask(args.taskId, args.relatedTaskId);
1544
+ const guidance = addGuidance("action=show (view task details)");
1545
+ return `Linked ${args.taskId} ↔ ${args.relatedTaskId} as related${guidance}`;
1546
+ }),
1547
+
1548
+ "stack-log": (ship, args, _ctx) =>
1549
+ Effect.gen(function* () {
1550
+ const changes = yield* ship.getStackLog(args.workdir);
1551
+ if (changes.length === 0) {
1552
+ return "No changes in stack (working copy is on trunk)";
1553
+ }
1554
+ return `Stack (${changes.length} changes):\n\n${changes
1555
+ .map((c) => {
1556
+ const marker = c.isWorkingCopy ? "@" : "○";
1557
+ const empty = c.isEmpty ? " (empty)" : "";
1558
+ const bookmarks = c.bookmarks.length > 0 ? ` [${c.bookmarks.join(", ")}]` : "";
1559
+ const desc = c.description.split("\n")[0] || "(no description)";
1560
+ return `${marker} ${c.changeId.slice(0, 8)} ${desc}${empty}${bookmarks}`;
1561
+ })
1562
+ .join("\n")}`;
1563
+ }),
1564
+
1565
+ "stack-status": (ship, args, _ctx) =>
1566
+ Effect.gen(function* () {
1567
+ const status = yield* ship.getStackStatus(args.workdir);
1568
+ if (!status.isRepo) {
1569
+ return `Error: ${status.error || "Not a jj repository"}`;
1570
+ }
1571
+ if (!status.change) {
1572
+ return "Error: Could not get current change";
1573
+ }
1574
+ const c = status.change;
1575
+ let output = `Change: ${c.changeId.slice(0, 8)}
1576
+ Commit: ${c.commitId.slice(0, 12)}
1577
+ Description: ${c.description.split("\n")[0] || "(no description)"}`;
1578
+ if (c.bookmarks.length > 0) {
1579
+ output += `\nBookmarks: ${c.bookmarks.join(", ")}`;
1580
+ }
1581
+ output += `\nStatus: ${c.isEmpty ? "empty (no changes)" : "has changes"}`;
1582
+ const guidance = addGuidance(
1583
+ "action=stack-submit (push changes) | action=stack-sync (fetch latest)",
1584
+ );
1585
+ return output + guidance;
1586
+ }),
1587
+
1588
+ "stack-create": (ship, args, _ctx) =>
1589
+ Effect.gen(function* () {
1590
+ const result = yield* ship.createStackChange({
1591
+ message: args.message,
1592
+ bookmark: args.bookmark,
1593
+ noWorkspace: args.noWorkspace,
1594
+ taskId: args.taskId,
1595
+ workdir: args.workdir,
1596
+ });
1597
+ if (!result.created) {
1598
+ return `Error: ${result.error || "Failed to create change"}`;
1599
+ }
1600
+ let output = `Created change: ${result.changeId}`;
1601
+ if (result.bookmark) {
1602
+ output += `\nCreated bookmark: ${result.bookmark}`;
1603
+ }
1604
+ if (result.workspace?.created) {
1605
+ output += `\nCreated workspace: ${result.workspace.name} at ${result.workspace.path}`;
1606
+ const guidance = addGuidance(
1607
+ "Implement the task (edit files) | action=stack-status (check change state)",
1608
+ {
1609
+ workdir: result.workspace.path,
1610
+ note: "Workspace created. Use the workdir above for all subsequent commands.",
1611
+ },
1612
+ );
1613
+ output += guidance;
1614
+ } else {
1615
+ const guidance = addGuidance(
1616
+ "Implement the task (edit files) | action=stack-status (check change state)",
1617
+ );
1618
+ output += guidance;
1619
+ }
1620
+ return output;
1621
+ }),
1622
+
1623
+ "stack-describe": (ship, args, _ctx) =>
1624
+ Effect.gen(function* () {
1625
+ if (!args.message && !args.title) {
1626
+ return "Error: Either message or title is required for stack-describe action";
1627
+ }
1628
+ const result = yield* ship.describeStackChange(
1629
+ { message: args.message, title: args.title, description: args.description },
1630
+ args.workdir,
1631
+ );
1632
+ if (!result.updated) {
1633
+ return `Error: ${result.error || "Failed to update description"}`;
1634
+ }
1635
+ return `Updated change ${result.changeId?.slice(0, 8) || ""}\nDescription: ${result.description || args.title || args.message}`;
1636
+ }),
1637
+
1638
+ "stack-sync": (ship, args, ctx) =>
1639
+ Effect.gen(function* () {
1640
+ const result = yield* ship.syncStack(args.workdir);
1641
+ if (result.error) {
1642
+ return `Sync failed: [${result.error.tag}] ${result.error.message}`;
1643
+ }
1644
+
1645
+ const parts: string[] = [];
1646
+
1647
+ if (result.abandonedMergedChanges && result.abandonedMergedChanges.length > 0) {
1648
+ parts.push("Auto-abandoned merged changes:");
1649
+ for (const change of result.abandonedMergedChanges) {
1650
+ const bookmarkInfo = change.bookmark ? ` (${change.bookmark})` : "";
1651
+ parts.push(` - ${change.changeId}${bookmarkInfo}`);
1652
+ }
1653
+ parts.push("");
1654
+ }
1655
+
1656
+ if (result.stackFullyMerged) {
1657
+ parts.push("Stack fully merged! All changes are now in trunk.");
1658
+ if (result.cleanedUpWorkspace) {
1659
+ parts.push(`Cleaned up workspace: ${result.cleanedUpWorkspace}`);
1660
+ }
1661
+ parts.push(` Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}`);
1662
+ const guidance = addGuidance(
1663
+ "action=done (mark task complete) | action=ready (find next task)",
1664
+ {
1665
+ workdir: ctx.mainRepoPath,
1666
+ skill: true,
1667
+ note: `Workspace '${result.cleanedUpWorkspace}' was deleted. Use the workdir above for subsequent commands.`,
1668
+ },
1669
+ );
1670
+ parts.push(guidance);
1671
+ } else if (result.conflicted) {
1672
+ parts.push("Sync completed with conflicts!");
1673
+ parts.push(` Fetched: yes`);
1674
+ parts.push(` Rebased: yes (with conflicts)`);
1675
+ parts.push(` Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}`);
1676
+ parts.push(` Stack: ${result.stackSize} change(s)`);
1677
+ parts.push("");
1678
+ parts.push("Resolve conflicts with 'jj status' and edit the conflicted files.");
1679
+ const guidance = addGuidance(
1680
+ "resolve conflicts manually | action=stack-status (check conflict state)",
1681
+ {
1682
+ skill: true,
1683
+ note: "Conflicts detected during rebase. Resolve them before continuing.",
1684
+ },
1685
+ );
1686
+ parts.push(guidance);
1687
+ } else if (!result.rebased) {
1688
+ parts.push("Already up to date.");
1689
+ parts.push(` Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}`);
1690
+ parts.push(` Stack: ${result.stackSize} change(s)`);
1691
+ const guidance = addGuidance("continue work | action=stack-submit (if ready to push)");
1692
+ parts.push(guidance);
1693
+ } else {
1694
+ parts.push("Sync completed successfully.");
1695
+ parts.push(` Fetched: yes`);
1696
+ parts.push(` Rebased: yes`);
1697
+ parts.push(` Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}`);
1698
+ parts.push(` Stack: ${result.stackSize} change(s)`);
1699
+ const guidance = addGuidance("continue work | action=stack-submit (push rebased changes)");
1700
+ parts.push(guidance);
1701
+ }
1702
+
1703
+ return parts.join("\n");
1704
+ }),
1705
+
1706
+ "stack-restack": (ship, args, _ctx) =>
1707
+ Effect.gen(function* () {
1708
+ const result = yield* ship.restackStack(args.workdir);
1709
+ if (result.error) {
1710
+ return `Restack failed: ${result.error}`;
1711
+ }
1712
+ if (!result.restacked) {
1713
+ return `Nothing to restack (working copy is on trunk).
1714
+ Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}`;
1715
+ }
1716
+ if (result.conflicted) {
1717
+ return `Restack completed with conflicts!
1718
+ Rebased: yes (with conflicts)
1719
+ Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}
1720
+ Stack: ${result.stackSize} change(s)
1721
+
1722
+ Resolve conflicts with 'jj status' and edit the conflicted files.`;
1723
+ }
1724
+ return `Restack completed successfully.
1725
+ Rebased: ${result.stackSize} change(s)
1726
+ Trunk: ${result.trunkChangeId?.slice(0, 12) || "unknown"}
1727
+ Stack: ${result.stackSize} change(s)`;
1728
+ }),
1729
+
1730
+ "stack-submit": (ship, args, ctx) =>
1731
+ Effect.gen(function* () {
1732
+ const subscribeSessionId = args.sessionId || ctx.sessionId;
1733
+
1734
+ const result = yield* ship.submitStack({
1735
+ draft: args.draft,
1736
+ title: args.title,
1737
+ body: args.body,
1738
+ subscribe: subscribeSessionId,
1739
+ workdir: args.workdir,
1740
+ });
1741
+ if (result.error) {
1742
+ if (result.pushed) {
1743
+ return `Pushed bookmark: ${result.bookmark}\nBase branch: ${result.baseBranch || "main"}\nWarning: ${result.error}`;
1744
+ }
1745
+ return `Error: ${result.error}`;
1746
+ }
1747
+ let output = "";
1748
+ if (result.pr) {
1749
+ const statusMsg =
1750
+ result.pr.status === "created"
1751
+ ? "Created PR"
1752
+ : result.pr.status === "exists"
1753
+ ? "PR already exists"
1754
+ : "Updated PR";
1755
+ output = `Pushed bookmark: ${result.bookmark}\nBase branch: ${result.baseBranch || "main"}\n${statusMsg}: #${result.pr.number}\nURL: ${result.pr.url}`;
1756
+ } else {
1757
+ output = `Pushed bookmark: ${result.bookmark}\nBase branch: ${result.baseBranch || "main"}`;
1758
+ }
1759
+ if (result.subscribed) {
1760
+ output += `\n\nAuto-subscribed to stack PRs: ${result.subscribed.prNumbers.join(", ")}`;
1761
+ }
1762
+ // Add guidance for submit
1763
+ const prCreated = result.pr?.status === "created" || result.pr?.status === "updated";
1764
+ const guidance = addGuidance(
1765
+ prCreated
1766
+ ? "Wait for review | action=stack-create (start next change in stack) | action=done (if single-change task)"
1767
+ : "action=stack-status (check change state)",
1768
+ );
1769
+ output += guidance;
1770
+ return output;
1771
+ }),
1772
+
1773
+ "stack-squash": (ship, args, _ctx) =>
1774
+ Effect.gen(function* () {
1775
+ if (!args.message) {
1776
+ return "Error: message is required for stack-squash action";
1777
+ }
1778
+ const result = yield* ship.squashStack(args.message, args.workdir);
1779
+ if (!result.squashed) {
1780
+ return `Error: ${result.error || "Failed to squash"}`;
1781
+ }
1782
+ return `Squashed into ${result.intoChangeId?.slice(0, 8) || "parent"}\nDescription: ${result.description?.split("\n")[0] || "(no description)"}`;
1783
+ }),
1784
+
1785
+ "stack-abandon": (ship, args, _ctx) =>
1786
+ Effect.gen(function* () {
1787
+ const result = yield* ship.abandonStack(args.changeId, args.workdir);
1788
+ if (!result.abandoned) {
1789
+ return `Error: ${result.error || "Failed to abandon"}`;
1790
+ }
1791
+ let output = `Abandoned ${result.changeId?.slice(0, 8) || "change"}\nWorking copy now at: ${result.newWorkingCopy?.slice(0, 8) || "unknown"}`;
1792
+ // Note: We don't know if workspace was deleted from this result
1793
+ // The guidance for workspace deletion happens in stack-sync when stack is fully merged
1794
+ const guidance = addGuidance("action=stack-log (view remaining stack) | continue work");
1795
+ return output + guidance;
1796
+ }),
1797
+
1798
+ "stack-up": (ship, args, _ctx) =>
1799
+ Effect.gen(function* () {
1800
+ const result = yield* ship.stackUp(args.workdir);
1801
+ if (!result.moved) {
1802
+ return result.error || "Already at the tip of the stack (no child change)";
1803
+ }
1804
+ return `Moved up in stack:\n From: ${result.from?.changeId.slice(0, 8) || "unknown"} ${result.from?.description || ""}\n To: ${result.to?.changeId.slice(0, 8) || "unknown"} ${result.to?.description || ""}`;
1805
+ }),
1806
+
1807
+ "stack-down": (ship, args, _ctx) =>
1808
+ Effect.gen(function* () {
1809
+ const result = yield* ship.stackDown(args.workdir);
1810
+ if (!result.moved) {
1811
+ return result.error || "Already at the base of the stack (on trunk)";
1812
+ }
1813
+ return `Moved down in stack:\n From: ${result.from?.changeId.slice(0, 8) || "unknown"} ${result.from?.description || ""}\n To: ${result.to?.changeId.slice(0, 8) || "unknown"} ${result.to?.description || ""}`;
1814
+ }),
1815
+
1816
+ "stack-undo": (ship, args, _ctx) =>
1817
+ Effect.gen(function* () {
1818
+ const result = yield* ship.stackUndo(args.workdir);
1819
+ if (!result.undone) {
1820
+ return `Error: ${result.error || "Failed to undo"}`;
1821
+ }
1822
+ return result.operation ? `Undone: ${result.operation}` : "Undone last operation";
1823
+ }),
1824
+
1825
+ "stack-update-stale": (ship, args, _ctx) =>
1826
+ Effect.gen(function* () {
1827
+ const result = yield* ship.stackUpdateStale(args.workdir);
1828
+ if (!result.updated) {
1829
+ return `Error: ${result.error || "Failed to update stale workspace"}`;
1830
+ }
1831
+ return result.changeId
1832
+ ? `Working copy updated. Now at: ${result.changeId}`
1833
+ : "Working copy updated.";
1834
+ }),
1835
+
1836
+ "stack-bookmark": (ship, args, _ctx) =>
1837
+ Effect.gen(function* () {
1838
+ if (!args.name) {
1839
+ return "Error: name is required for stack-bookmark action";
1840
+ }
1841
+ const result = yield* ship.bookmarkStack(args.name, args.move, args.workdir);
1842
+ if (!result.success) {
1843
+ return `Error: ${result.error || "Failed to create/move bookmark"}`;
1844
+ }
1845
+ const action = result.action === "moved" ? "Moved" : "Created";
1846
+ return `${action} bookmark '${result.bookmark}' at ${result.changeId?.slice(0, 8) || "current change"}`;
1847
+ }),
1848
+
1849
+ "stack-workspaces": (ship, args, _ctx) =>
1850
+ Effect.gen(function* () {
1851
+ const workspaces = yield* ship.listWorkspaces(args.workdir);
1852
+ if (workspaces.length === 0) {
1853
+ return "No workspaces found.";
1854
+ }
1855
+ return `Workspaces (${workspaces.length}):\n\n${workspaces
1856
+ .map((ws: WorkspaceOutput) => {
1857
+ const defaultMark = ws.isDefault ? " (default)" : "";
1858
+ const stack = ws.stackName ? ` stack:${ws.stackName}` : "";
1859
+ const task = ws.taskId ? ` task:${ws.taskId}` : "";
1860
+ return `${ws.name}${defaultMark}${stack}${task}\n Change: ${ws.changeId} - ${ws.description}\n Path: ${ws.path}`;
1861
+ })
1862
+ .join("\n\n")}`;
1863
+ }),
1864
+
1865
+ "stack-remove-workspace": (ship, args, ctx) =>
1866
+ Effect.gen(function* () {
1867
+ if (!args.name) {
1868
+ return "Error: name is required for stack-remove-workspace action";
1869
+ }
1870
+ const result = yield* ship.removeWorkspace(args.name, args.deleteFiles, args.workdir);
1871
+ if (!result.removed) {
1872
+ return `Error: ${result.error || "Failed to remove workspace"}`;
1873
+ }
1874
+ let output = `Removed workspace: ${result.name}`;
1875
+ if (result.filesDeleted !== undefined) {
1876
+ output += result.filesDeleted ? "\nFiles deleted." : "\nFiles remain on disk.";
1877
+ }
1878
+ const guidance = addGuidance(
1879
+ "action=ready (find next task) | action=stack-workspaces (list remaining)",
1880
+ {
1881
+ workdir: ctx.mainRepoPath,
1882
+ note: "Workspace removed. Use the workdir above for subsequent commands.",
1883
+ },
1884
+ );
1885
+ return output + guidance;
1886
+ }),
1887
+
1888
+ "webhook-start": (ship, args, _ctx) =>
1889
+ Effect.gen(function* () {
1890
+ const result = yield* ship.startWebhook(args.events);
1891
+ if (!result.started) {
1892
+ return `Error: ${result.error}${result.pid ? ` (PID: ${result.pid})` : ""}`;
1893
+ }
1894
+ return `Webhook forwarding started (PID: ${result.pid})
1895
+ Events: ${result.events?.join(", ") || "default"}
1896
+
1897
+ GitHub events will be forwarded to the current OpenCode session.
1898
+ Use action 'webhook-stop' to stop forwarding.`;
1899
+ }),
1900
+
1901
+ "webhook-stop": (ship, _args, _ctx) =>
1902
+ Effect.gen(function* () {
1903
+ const result = yield* ship.stopWebhook();
1904
+ if (!result.stopped) {
1905
+ return result.error || "No webhook forwarding process is running";
1906
+ }
1907
+ return "Webhook forwarding stopped.";
1908
+ }),
1909
+
1910
+ "webhook-status": (ship, _args, _ctx) =>
1911
+ Effect.gen(function* () {
1912
+ const status = yield* ship.getWebhookStatus();
1913
+ if (status.running) {
1914
+ return `Webhook forwarding is running (PID: ${status.pid})`;
1915
+ }
1916
+ return "Webhook forwarding is not running.";
1917
+ }),
1918
+
1919
+ "webhook-daemon-status": (ship, _args, _ctx) =>
1920
+ Effect.gen(function* () {
1921
+ const status = yield* ship.getDaemonStatus();
1922
+ if (!status.running) {
1923
+ return "Webhook daemon is not running.\n\nStart it with: ship webhook start";
1924
+ }
1925
+ let output = `Webhook Daemon Status
1926
+ ─────────────────────────────────────────
1927
+ Status: Running
1928
+ PID: ${status.pid || "unknown"}
1929
+ Repository: ${status.repo || "unknown"}
1930
+ GitHub WebSocket: ${status.connectedToGitHub ? "Connected" : "Disconnected"}
1931
+ Uptime: ${status.uptime ? `${status.uptime}s` : "unknown"}
1932
+
1933
+ Subscriptions:`;
1934
+ if (!status.subscriptions || status.subscriptions.length === 0) {
1935
+ output += "\n No active subscriptions.";
1936
+ } else {
1937
+ for (const sub of status.subscriptions) {
1938
+ output += `\n Session ${sub.sessionId}: PRs ${sub.prNumbers.join(", ")}`;
1939
+ }
1940
+ }
1941
+ return output;
1942
+ }),
1943
+
1944
+ "webhook-subscribe": (ship, args, ctx) =>
1945
+ Effect.gen(function* () {
1946
+ const sessionId = args.sessionId || ctx.sessionId;
1947
+ if (!sessionId) {
1948
+ return "Error: sessionId is required for webhook-subscribe action (not provided and could not auto-detect from context)";
1949
+ }
1950
+ if (!args.prNumbers || args.prNumbers.length === 0) {
1951
+ return "Error: prNumbers is required for webhook-subscribe action";
1952
+ }
1953
+ const result = yield* ship.subscribeToPRs(sessionId, args.prNumbers, ctx.serverUrl);
1954
+ if (!result.subscribed) {
1955
+ return `Error: ${result.error || "Failed to subscribe"}`;
1956
+ }
1957
+ const serverInfo = ctx.serverUrl ? ` (server: ${ctx.serverUrl})` : "";
1958
+ return `Subscribed session ${sessionId} to PRs: ${args.prNumbers.join(", ")}${serverInfo}
1959
+
1960
+ The daemon will forward GitHub events for these PRs to your session.
1961
+ Use 'webhook-unsubscribe' to stop receiving events.`;
1962
+ }),
11
1963
 
12
- import type { Plugin, PluginInput } from "@opencode-ai/plugin";
13
- import { tool as createTool } from "@opencode-ai/plugin";
1964
+ "webhook-unsubscribe": (ship, args, ctx) =>
1965
+ Effect.gen(function* () {
1966
+ const sessionId = args.sessionId || ctx.sessionId;
1967
+ if (!sessionId) {
1968
+ return "Error: sessionId is required for webhook-unsubscribe action";
1969
+ }
1970
+ if (!args.prNumbers || args.prNumbers.length === 0) {
1971
+ return "Error: prNumbers is required for webhook-unsubscribe action";
1972
+ }
1973
+ const result = yield* ship.unsubscribeFromPRs(sessionId, args.prNumbers, ctx.serverUrl);
1974
+ if (!result.unsubscribed) {
1975
+ return `Error: ${result.error || "Failed to unsubscribe"}`;
1976
+ }
1977
+ return `Unsubscribed session ${sessionId} from PRs: ${args.prNumbers.join(", ")}`;
1978
+ }),
14
1979
 
15
- type OpencodeClient = PluginInput["client"];
1980
+ "webhook-cleanup": (ship, _args, _ctx) =>
1981
+ Effect.gen(function* () {
1982
+ const result = yield* ship.cleanupStaleSubscriptions();
1983
+ if (!result.success) {
1984
+ return `Error: ${result.error || "Failed to cleanup"}`;
1985
+ }
1986
+ if (result.removedSessions.length === 0) {
1987
+ return "No stale subscriptions found. All subscribed sessions are still active.";
1988
+ }
1989
+ return `Cleaned up ${result.removedSessions.length} stale subscription(s):\n${result.removedSessions.map((s: string) => ` - ${s}`).join("\n")}\n\nThese sessions no longer exist in OpenCode.`;
1990
+ }),
16
1991
 
17
- const SHIP_GUIDANCE = `## Ship CLI Guidance
1992
+ // PR reviews action
1993
+ "pr-reviews": (ship, args, _ctx) =>
1994
+ Effect.gen(function* () {
1995
+ const result = yield* ship.getPrReviews(args.prNumber, args.unresolved, args.workdir);
18
1996
 
19
- Ship integrates Linear issue tracking with your development workflow. Use it to:
20
- - View and manage tasks assigned to you
21
- - Track task dependencies (blockers)
22
- - Start/complete work on tasks
23
- - Create new tasks
1997
+ if (result.error) {
1998
+ return `Error: ${result.error}`;
1999
+ }
24
2000
 
25
- ### Quick Commands
26
- - \`ship ready\` - Tasks you can work on (no blockers)
27
- - \`ship blocked\` - Tasks waiting on dependencies
28
- - \`ship list\` - All tasks
29
- - \`ship show <ID>\` - Task details
30
- - \`ship start <ID>\` - Begin working on task
31
- - \`ship done <ID>\` - Mark task complete
32
- - \`ship create "title"\` - Create new task
33
-
34
- ### Best Practices
35
- 1. Check \`ship ready\` to see what can be worked on
36
- 2. Use \`ship start\` before beginning work
37
- 3. Use \`ship done\` when completing tasks
38
- 4. Check blockers before starting dependent tasks`;
2001
+ // Format the output in a human-readable way similar to the CLI
2002
+ const lines: string[] = [];
39
2003
 
40
- /**
41
- * Get the current model/agent context for a session by querying messages.
42
- */
43
- async function getSessionContext(
44
- client: OpencodeClient,
45
- sessionID: string
46
- ): Promise<
47
- { model?: { providerID: string; modelID: string }; agent?: string } | undefined
48
- > {
49
- try {
50
- const response = await client.session.messages({
51
- path: { id: sessionID },
52
- query: { limit: 50 },
53
- });
54
-
55
- if (response.data) {
56
- for (const msg of response.data) {
57
- if (msg.info.role === "user" && "model" in msg.info && msg.info.model) {
58
- return { model: msg.info.model, agent: msg.info.agent };
2004
+ lines.push(`## PR #${result.prNumber}${result.prTitle ? `: ${result.prTitle}` : ""}`);
2005
+ if (result.prUrl) {
2006
+ lines.push(`URL: ${result.prUrl}`);
2007
+ }
2008
+ lines.push("");
2009
+
2010
+ // Reviews section
2011
+ if (result.reviews.length > 0) {
2012
+ lines.push("### Reviews");
2013
+ const sortedReviews = [...result.reviews].sort(
2014
+ (a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(),
2015
+ );
2016
+ for (const review of sortedReviews) {
2017
+ const stateLabel =
2018
+ review.state === "APPROVED"
2019
+ ? "[APPROVED]"
2020
+ : review.state === "CHANGES_REQUESTED"
2021
+ ? "[CHANGES_REQUESTED]"
2022
+ : `[${review.state}]`;
2023
+ lines.push(`- @${review.author}: ${stateLabel}`);
2024
+ if (review.body) {
2025
+ const bodyLines = review.body.split("\n").map((l: string) => ` ${l}`);
2026
+ lines.push(...bodyLines);
2027
+ }
59
2028
  }
2029
+ lines.push("");
60
2030
  }
61
- }
62
- } catch {
63
- // On error, return undefined (let opencode use its default)
64
- }
65
2031
 
66
- return undefined;
67
- }
2032
+ // Code comments section
2033
+ const fileKeys = Object.keys(result.commentsByFile);
2034
+ if (fileKeys.length > 0) {
2035
+ lines.push(`### Code Comments (${result.codeComments.length} total)`);
2036
+ lines.push("");
68
2037
 
69
- /**
70
- * Inject ship context into a session.
71
- *
72
- * Runs `ship prime` and injects the output along with CLI guidance.
73
- * Silently skips if ship is not installed or not initialized.
74
- */
75
- async function injectShipContext(
76
- client: OpencodeClient,
77
- $: PluginInput["$"],
78
- sessionID: string,
79
- context?: { model?: { providerID: string; modelID: string }; agent?: string }
80
- ): Promise<void> {
81
- try {
82
- // Use quiet() to prevent any output from bleeding into TUI
83
- const primeOutput = await $`ship prime`.quiet().text();
84
-
85
- if (!primeOutput || primeOutput.trim() === "") {
86
- return;
87
- }
2038
+ for (const filePath of fileKeys.sort()) {
2039
+ const fileComments = result.commentsByFile[filePath];
2040
+ lines.push(`#### ${filePath}`);
88
2041
 
89
- // Wrap the plain markdown output with XML tags (like beads plugin does)
90
- const shipContext = `<ship-context>
91
- ${primeOutput.trim()}
92
- </ship-context>
93
-
94
- ${SHIP_GUIDANCE}`;
95
-
96
- // Inject content via noReply + synthetic
97
- // Must pass model and agent to prevent mode/model switching
98
- await client.session.prompt({
99
- path: { id: sessionID },
100
- body: {
101
- noReply: true,
102
- model: context?.model,
103
- agent: context?.agent,
104
- parts: [{ type: "text", text: shipContext, synthetic: true }],
105
- },
106
- });
107
- } catch {
108
- // Silent skip if ship prime fails (not installed or not initialized)
109
- }
110
- }
2042
+ for (const comment of fileComments) {
2043
+ const lineInfo = comment.line !== null ? `:${comment.line}` : "";
2044
+ lines.push(`**${filePath}${lineInfo}** - @${comment.author}:`);
2045
+ if (comment.diffHunk) {
2046
+ lines.push("```diff");
2047
+ lines.push(comment.diffHunk);
2048
+ lines.push("```");
2049
+ }
2050
+ const bodyLines = comment.body.split("\n").map((l: string) => `> ${l}`);
2051
+ lines.push(...bodyLines);
2052
+ lines.push("");
2053
+ }
2054
+ }
2055
+ }
111
2056
 
112
- /**
113
- * Execute ship CLI command and return output
114
- */
115
- async function runShip(
116
- $: PluginInput["$"],
117
- args: string[]
118
- ): Promise<{ success: boolean; output: string }> {
119
- try {
120
- // Use quiet() to prevent output from bleeding into TUI
121
- const result = await $`ship ${args}`.quiet().nothrow();
122
- const stdout = await new Response(result.stdout).text();
123
- const stderr = await new Response(result.stderr).text();
124
-
125
- if (result.exitCode !== 0) {
126
- return { success: false, output: stderr || stdout };
127
- }
2057
+ // Conversation comments section
2058
+ if (result.conversationComments.length > 0) {
2059
+ lines.push("### Conversation");
2060
+ for (const comment of result.conversationComments) {
2061
+ lines.push(`- @${comment.author}:`);
2062
+ const bodyLines = comment.body.split("\n").map((l: string) => ` ${l}`);
2063
+ lines.push(...bodyLines);
2064
+ lines.push("");
2065
+ }
2066
+ }
128
2067
 
129
- return { success: true, output: stdout };
130
- } catch (error) {
131
- return {
132
- success: false,
133
- output: `Failed to run ship: ${error instanceof Error ? error.message : String(error)}`,
134
- };
135
- }
136
- }
2068
+ // Summary if no feedback
2069
+ if (
2070
+ result.reviews.length === 0 &&
2071
+ result.codeComments.length === 0 &&
2072
+ result.conversationComments.length === 0
2073
+ ) {
2074
+ lines.push("No reviews or comments found.");
2075
+ }
137
2076
 
138
- /**
139
- * Check if ship is configured by attempting to run a ship command.
140
- * This handles both local (.ship/config.yaml) and global configurations.
141
- */
142
- async function isShipConfigured($: PluginInput["$"]): Promise<boolean> {
143
- try {
144
- // Try running ship prime - it will fail if not configured
145
- // Use quiet() to suppress stdout/stderr from bleeding into TUI
146
- const result = await $`ship prime`.quiet().nothrow();
147
- return result.exitCode === 0;
148
- } catch {
149
- return false;
150
- }
151
- }
2077
+ const guidance = addGuidance("address review feedback | action=stack-submit (push updates)");
2078
+ return lines.join("\n").trim() + guidance;
2079
+ }),
152
2080
 
153
- /**
154
- * Format a list of tasks for display
155
- */
156
- function formatTaskList(
157
- tasks: Array<{
158
- identifier: string;
159
- title: string;
160
- priority: string;
161
- status: string;
162
- url: string;
163
- }>
164
- ): string {
165
- return tasks
166
- .map((t) => {
167
- const priority =
168
- t.priority === "urgent"
169
- ? "[!]"
170
- : t.priority === "high"
171
- ? "[^]"
172
- : " ";
173
- return `${priority} ${t.identifier.padEnd(10)} ${t.status.padEnd(12)} ${t.title}`;
174
- })
175
- .join("\n");
176
- }
2081
+ // Milestone actions
2082
+ "milestone-list": (ship, _args, _ctx) =>
2083
+ Effect.gen(function* () {
2084
+ const milestones = yield* ship.listMilestones();
2085
+ if (milestones.length === 0) {
2086
+ return "No milestones found for this project.\n\nCreate one with: milestone-create action";
2087
+ }
2088
+ return `Milestones (${milestones.length}):\n\n${milestones
2089
+ .map((m) => {
2090
+ const targetDate = m.targetDate ? ` (due: ${m.targetDate})` : "";
2091
+ return `${m.slug.padEnd(25)} ${m.name}${targetDate}`;
2092
+ })
2093
+ .join("\n")}`;
2094
+ }),
177
2095
 
178
- /**
179
- * Format task details for display
180
- */
181
- function formatTaskDetails(task: {
182
- identifier: string;
183
- title: string;
184
- description?: string;
185
- priority: string;
186
- status: string;
187
- labels: string[];
188
- url: string;
189
- branchName?: string;
190
- }): string {
191
- let output = `# ${task.identifier}: ${task.title}
2096
+ "milestone-show": (ship, args, _ctx) =>
2097
+ Effect.gen(function* () {
2098
+ if (!args.milestoneId) {
2099
+ return "Error: milestoneId is required for milestone-show action";
2100
+ }
2101
+ const milestone = yield* ship.getMilestone(args.milestoneId);
2102
+ let output = `# ${milestone.name}\n\n`;
2103
+ output += `**Slug:** ${milestone.slug}\n`;
2104
+ output += `**ID:** ${milestone.id}\n`;
2105
+ if (milestone.targetDate) {
2106
+ output += `**Target Date:** ${milestone.targetDate}\n`;
2107
+ }
2108
+ if (milestone.description) {
2109
+ output += `\n## Description\n\n${milestone.description}`;
2110
+ }
2111
+ return output;
2112
+ }),
192
2113
 
193
- **Status:** ${task.status}
194
- **Priority:** ${task.priority}
195
- **Labels:** ${task.labels.length > 0 ? task.labels.join(", ") : "none"}
196
- **URL:** ${task.url}`;
2114
+ "milestone-create": (ship, args, _ctx) =>
2115
+ Effect.gen(function* () {
2116
+ if (!args.milestoneName) {
2117
+ return "Error: milestoneName is required for milestone-create action";
2118
+ }
2119
+ const milestone = yield* ship.createMilestone({
2120
+ name: args.milestoneName,
2121
+ description: args.milestoneDescription,
2122
+ targetDate: args.milestoneTargetDate,
2123
+ });
2124
+ let output = `Created milestone: ${milestone.name}\nSlug: ${milestone.slug}`;
2125
+ if (milestone.targetDate) {
2126
+ output += `\nTarget Date: ${milestone.targetDate}`;
2127
+ }
2128
+ return output;
2129
+ }),
197
2130
 
198
- if (task.branchName) {
199
- output += `\n**Branch:** ${task.branchName}`;
200
- }
2131
+ "milestone-update": (ship, args, _ctx) =>
2132
+ Effect.gen(function* () {
2133
+ if (!args.milestoneId) {
2134
+ return "Error: milestoneId is required for milestone-update action";
2135
+ }
2136
+ if (!args.milestoneName && !args.milestoneDescription && !args.milestoneTargetDate) {
2137
+ return "Error: at least one of milestoneName, milestoneDescription, or milestoneTargetDate is required";
2138
+ }
2139
+ const milestone = yield* ship.updateMilestone(args.milestoneId, {
2140
+ name: args.milestoneName,
2141
+ description: args.milestoneDescription,
2142
+ targetDate: args.milestoneTargetDate,
2143
+ });
2144
+ let output = `Updated milestone: ${milestone.name}\nSlug: ${milestone.slug}`;
2145
+ if (milestone.targetDate) {
2146
+ output += `\nTarget Date: ${milestone.targetDate}`;
2147
+ }
2148
+ return output;
2149
+ }),
201
2150
 
202
- if (task.description) {
203
- output += `\n\n## Description\n\n${task.description}`;
204
- }
2151
+ "milestone-delete": (ship, args, _ctx) =>
2152
+ Effect.gen(function* () {
2153
+ if (!args.milestoneId) {
2154
+ return "Error: milestoneId is required for milestone-delete action";
2155
+ }
2156
+ yield* ship.deleteMilestone(args.milestoneId);
2157
+ return `Deleted milestone: ${args.milestoneId}`;
2158
+ }),
205
2159
 
206
- return output;
207
- }
2160
+ "task-set-milestone": (ship, args, _ctx) =>
2161
+ Effect.gen(function* () {
2162
+ if (!args.taskId) {
2163
+ return "Error: taskId is required for task-set-milestone action";
2164
+ }
2165
+ if (!args.milestoneId) {
2166
+ return "Error: milestoneId is required for task-set-milestone action";
2167
+ }
2168
+ const task = yield* ship.setTaskMilestone(args.taskId, args.milestoneId);
2169
+ return `Assigned ${task.identifier} to milestone: ${task.milestoneName || args.milestoneId}\nURL: ${task.url}`;
2170
+ }),
2171
+
2172
+ "task-unset-milestone": (ship, args, _ctx) =>
2173
+ Effect.gen(function* () {
2174
+ if (!args.taskId) {
2175
+ return "Error: taskId is required for task-unset-milestone action";
2176
+ }
2177
+ const task = yield* ship.unsetTaskMilestone(args.taskId);
2178
+ return `Removed ${task.identifier} from its milestone\nURL: ${task.url}`;
2179
+ }),
2180
+ };
2181
+
2182
+ const executeAction = (
2183
+ args: ToolArgs,
2184
+ context: ActionContext,
2185
+ ): Effect.Effect<string, ShipCommandError | JsonParseError | ShipNotConfiguredError, ShipService> =>
2186
+ Effect.gen(function* () {
2187
+ const ship = yield* ShipService;
2188
+
2189
+ // Check configuration for all actions except status
2190
+ if (args.action !== "status") {
2191
+ const status = yield* ship
2192
+ .checkConfigured()
2193
+ .pipe(Effect.catchAll(() => Effect.succeed({ configured: false })));
2194
+ if (!status.configured) {
2195
+ return yield* new ShipNotConfiguredError({});
2196
+ }
2197
+ }
2198
+
2199
+ // Look up handler from the record
2200
+ const handler = actionHandlers[args.action];
2201
+ if (!handler) {
2202
+ return `Unknown action: ${args.action}`;
2203
+ }
2204
+
2205
+ return yield* handler(ship, args, context);
2206
+ });
2207
+
2208
+ // =============================================================================
2209
+ // Tool Creation
2210
+ // =============================================================================
208
2211
 
209
2212
  /**
210
- * Create ship tool with captured $ from plugin context
2213
+ * Create the ship tool with the opencode context.
2214
+ *
2215
+ * @param $ - Bun shell from opencode
2216
+ * @param directory - Current working directory from opencode (Instance.directory)
2217
+ * @returns ToolDefinition for the ship tool
211
2218
  */
212
- function createShipTool($: PluginInput["$"]) {
2219
+ const createShipTool = ($: BunShell, directory: string, serverUrl?: string): ToolDefinition => {
2220
+ const shellService = makeShellService($, directory);
2221
+ const ShellServiceLive = Layer.succeed(ShellService, shellService);
2222
+ const ShipServiceLive = Layer.effect(ShipService, makeShipService).pipe(
2223
+ Layer.provide(ShellServiceLive),
2224
+ );
2225
+
2226
+ const runEffect = <A, E>(effect: Effect.Effect<A, E, ShipService>): Promise<A> =>
2227
+ Effect.runPromise(Effect.provide(effect, ShipServiceLive));
2228
+
213
2229
  return createTool({
214
- description: `Linear task management for the current project.
2230
+ description: `Linear task management and VCS operations for the current project.
2231
+
2232
+ IMPORTANT: Always use this tool for VCS operations. NEVER run jj, gh, or git commands directly via bash.
2233
+ - Use stack-create instead of: jj new, jj describe, jj bookmark create
2234
+ - Use stack-describe instead of: jj describe
2235
+ - Use stack-submit instead of: jj git push, gh pr create
2236
+ - Use stack-sync instead of: jj git fetch, jj rebase
215
2237
 
216
2238
  Use this tool to:
217
2239
  - List tasks ready to work on (no blockers)
@@ -220,6 +2242,9 @@ Use this tool to:
220
2242
  - Create new tasks
221
2243
  - Manage task dependencies (blocking relationships)
222
2244
  - Get AI-optimized context about current work
2245
+ - Manage stacked changes (jj workflow)
2246
+ - Start/stop GitHub webhook forwarding for real-time event notifications
2247
+ - Subscribe to PR events via the webhook daemon (multi-session support)
223
2248
 
224
2249
  Requires ship to be configured in the project (.ship/config.yaml).
225
2250
  Run 'ship init' in the terminal first if not configured.`,
@@ -234,30 +2259,69 @@ Run 'ship init' in the terminal first if not configured.`,
234
2259
  "start",
235
2260
  "done",
236
2261
  "create",
2262
+ "update",
237
2263
  "block",
238
2264
  "unblock",
239
- "prime",
2265
+ "relate",
240
2266
  "status",
2267
+ "stack-log",
2268
+ "stack-status",
2269
+ "stack-create",
2270
+ "stack-describe",
2271
+ "stack-sync",
2272
+ "stack-restack",
2273
+ "stack-submit",
2274
+ "stack-squash",
2275
+ "stack-abandon",
2276
+ "stack-up",
2277
+ "stack-down",
2278
+ "stack-undo",
2279
+ "stack-update-stale",
2280
+ "stack-bookmark",
2281
+ "stack-workspaces",
2282
+ "stack-remove-workspace",
2283
+ "webhook-daemon-status",
2284
+ "webhook-subscribe",
2285
+ "webhook-unsubscribe",
2286
+ "webhook-cleanup",
2287
+ "pr-reviews",
2288
+ "milestone-list",
2289
+ "milestone-show",
2290
+ "milestone-create",
2291
+ "milestone-update",
2292
+ "milestone-delete",
2293
+ "task-set-milestone",
2294
+ "task-unset-milestone",
241
2295
  ])
242
2296
  .describe(
243
- "Action to perform: ready (unblocked tasks), list (all tasks), blocked (blocked tasks), show (task details), start (begin task), done (complete task), create (new task), block/unblock (dependencies), prime (AI context), status (current config)"
2297
+ "Action to perform: ready (unblocked tasks), list (all tasks), blocked (blocked tasks), show (task details), start (begin task), done (complete task), create (new task), update (modify task), block/unblock (dependencies), relate (link related tasks), status (current config), stack-log (view stack), stack-status (current change), stack-create (new change with workspace by default), stack-describe (update description), stack-bookmark (create or move a bookmark on current change), stack-sync (fetch and rebase), stack-restack (rebase stack onto trunk without fetching), stack-submit (push and create/update PR, auto-subscribes to webhook events), stack-squash (squash into parent), stack-abandon (abandon change), stack-up (move to child change toward tip), stack-down (move to parent change toward trunk), stack-undo (undo last jj operation), stack-update-stale (update stale working copy after workspace or remote changes), stack-workspaces (list all jj workspaces), stack-remove-workspace (remove a jj workspace), webhook-daemon-status (check daemon status), webhook-subscribe (subscribe to PR events), webhook-unsubscribe (unsubscribe from PR events), webhook-cleanup (cleanup stale subscriptions for sessions that no longer exist), pr-reviews (fetch PR reviews and comments), milestone-list (list project milestones), milestone-show (view milestone details), milestone-create (create new milestone), milestone-update (modify milestone), milestone-delete (delete milestone), task-set-milestone (assign task to milestone), task-unset-milestone (remove task from milestone)",
244
2298
  ),
245
2299
  taskId: createTool.schema
246
2300
  .string()
247
2301
  .optional()
248
- .describe("Task identifier (e.g., BRI-123) - required for show, start, done"),
2302
+ .describe(
2303
+ "Task identifier (e.g., BRI-123) - required for show, start, done, update; optional for stack-create to associate workspace with task",
2304
+ ),
249
2305
  title: createTool.schema
250
2306
  .string()
251
2307
  .optional()
252
- .describe("Task title - required for create"),
2308
+ .describe(
2309
+ "Title - for task create/update OR for stack-describe (first line of commit message)",
2310
+ ),
253
2311
  description: createTool.schema
254
2312
  .string()
255
2313
  .optional()
256
- .describe("Task description - optional for create"),
2314
+ .describe(
2315
+ "Description - for task create/update OR for stack-describe (commit body after title)",
2316
+ ),
257
2317
  priority: createTool.schema
258
2318
  .enum(["urgent", "high", "medium", "low", "none"])
259
2319
  .optional()
260
- .describe("Task priority - optional for create"),
2320
+ .describe("Task priority - optional for create/update"),
2321
+ status: createTool.schema
2322
+ .enum(["backlog", "todo", "in_progress", "in_review", "done", "cancelled"])
2323
+ .optional()
2324
+ .describe("Task status - optional for update"),
261
2325
  blocker: createTool.schema
262
2326
  .string()
263
2327
  .optional()
@@ -266,6 +2330,14 @@ Run 'ship init' in the terminal first if not configured.`,
266
2330
  .string()
267
2331
  .optional()
268
2332
  .describe("Blocked task ID - required for block/unblock"),
2333
+ relatedTaskId: createTool.schema
2334
+ .string()
2335
+ .optional()
2336
+ .describe("Related task ID - required for relate (use with taskId)"),
2337
+ parentId: createTool.schema
2338
+ .string()
2339
+ .optional()
2340
+ .describe("Parent task identifier (e.g., BRI-123) - for creating subtasks"),
269
2341
  filter: createTool.schema
270
2342
  .object({
271
2343
  status: createTool.schema
@@ -276,245 +2348,336 @@ Run 'ship init' in the terminal first if not configured.`,
276
2348
  })
277
2349
  .optional()
278
2350
  .describe("Filters for list action"),
2351
+ message: createTool.schema
2352
+ .string()
2353
+ .optional()
2354
+ .describe(
2355
+ "Message for stack-create. For stack-describe, prefer using title + description params for proper multi-line commits",
2356
+ ),
2357
+ bookmark: createTool.schema
2358
+ .string()
2359
+ .optional()
2360
+ .describe("Bookmark name for stack-create action"),
2361
+ noWorkspace: createTool.schema
2362
+ .boolean()
2363
+ .optional()
2364
+ .describe(
2365
+ "Skip workspace creation - for stack-create action (by default, workspace is created for isolated development)",
2366
+ ),
2367
+ name: createTool.schema
2368
+ .string()
2369
+ .optional()
2370
+ .describe(
2371
+ "Bookmark or workspace name - required for stack-bookmark and stack-remove-workspace actions",
2372
+ ),
2373
+ move: createTool.schema
2374
+ .boolean()
2375
+ .optional()
2376
+ .describe(
2377
+ "Move an existing bookmark instead of creating a new one - for stack-bookmark action",
2378
+ ),
2379
+ deleteFiles: createTool.schema
2380
+ .boolean()
2381
+ .optional()
2382
+ .describe(
2383
+ "Also delete the workspace directory from disk - for stack-remove-workspace action",
2384
+ ),
2385
+ workdir: createTool.schema
2386
+ .string()
2387
+ .optional()
2388
+ .describe(
2389
+ "Working directory for VCS operations - use this when operating in a jj workspace (e.g., the path returned by stack-create)",
2390
+ ),
2391
+ draft: createTool.schema
2392
+ .boolean()
2393
+ .optional()
2394
+ .describe("Create PR as draft - for stack-submit action"),
2395
+ body: createTool.schema
2396
+ .string()
2397
+ .optional()
2398
+ .describe("PR body - for stack-submit action (defaults to change description)"),
2399
+ changeId: createTool.schema
2400
+ .string()
2401
+ .optional()
2402
+ .describe("Change ID to abandon - for stack-abandon action (defaults to current @)"),
2403
+ events: createTool.schema
2404
+ .string()
2405
+ .optional()
2406
+ .describe(
2407
+ "Comma-separated GitHub events to forward (e.g., 'pull_request,check_run') - for webhook-start action",
2408
+ ),
2409
+ sessionId: createTool.schema
2410
+ .string()
2411
+ .optional()
2412
+ .describe("OpenCode session ID - for webhook-subscribe/unsubscribe actions"),
2413
+ prNumbers: createTool.schema
2414
+ .array(createTool.schema.number())
2415
+ .optional()
2416
+ .describe(
2417
+ "PR numbers to subscribe/unsubscribe - for webhook-subscribe/unsubscribe actions",
2418
+ ),
2419
+ prNumber: createTool.schema
2420
+ .number()
2421
+ .optional()
2422
+ .describe(
2423
+ "PR number - for pr-reviews action (defaults to current bookmark's PR if not provided)",
2424
+ ),
2425
+ unresolved: createTool.schema
2426
+ .boolean()
2427
+ .optional()
2428
+ .describe("Show only unresolved/actionable comments - for pr-reviews action"),
2429
+ milestoneId: createTool.schema
2430
+ .string()
2431
+ .optional()
2432
+ .describe(
2433
+ "Milestone identifier (slug like 'q1-release' or UUID) - required for milestone-show, milestone-update, task-set-milestone",
2434
+ ),
2435
+ milestoneName: createTool.schema
2436
+ .string()
2437
+ .optional()
2438
+ .describe("Milestone name - required for milestone-create, optional for milestone-update"),
2439
+ milestoneDescription: createTool.schema
2440
+ .string()
2441
+ .optional()
2442
+ .describe("Milestone description - optional for milestone-create/update"),
2443
+ milestoneTargetDate: createTool.schema
2444
+ .string()
2445
+ .optional()
2446
+ .describe(
2447
+ "Milestone target date (ISO format like '2024-03-31') - optional for milestone-create/update",
2448
+ ),
279
2449
  },
280
2450
 
281
- async execute(args) {
282
- // Check if ship is configured
283
- if (args.action !== "status") {
284
- const configured = await isShipConfigured($);
285
- if (!configured) {
286
- return `Ship is not configured in this project.
2451
+ async execute(args, context) {
2452
+ // Build action context with session ID, main repo path, and server URL
2453
+ const actionContext: ActionContext = {
2454
+ sessionId: context.sessionID,
2455
+ mainRepoPath: directory,
2456
+ serverUrl,
2457
+ };
2458
+ const result = await runEffect(
2459
+ executeAction(args, actionContext).pipe(
2460
+ Effect.catchAll((error) => {
2461
+ if (error._tag === "ShipNotConfiguredError") {
2462
+ return Effect.succeed(`Ship is not configured in this project.
287
2463
 
288
2464
  Run 'ship init' in the terminal to:
289
2465
  1. Authenticate with Linear (paste your API key from https://linear.app/settings/api)
290
2466
  2. Select your team
291
2467
  3. Optionally select a project
292
2468
 
293
- After that, you can use this tool to manage tasks.`;
294
- }
295
- }
2469
+ After that, you can use this tool to manage tasks.`);
2470
+ }
2471
+ if (error._tag === "ShipCommandError") {
2472
+ return Effect.succeed(`Command failed: ${error.message}`);
2473
+ }
2474
+ if (error._tag === "JsonParseError") {
2475
+ return Effect.succeed(`Failed to parse response: ${error.raw}`);
2476
+ }
2477
+ return Effect.succeed(`Unknown error: ${JSON.stringify(error)}`);
2478
+ }),
2479
+ ),
2480
+ );
2481
+ return result;
2482
+ },
2483
+ });
2484
+ };
296
2485
 
297
- switch (args.action) {
298
- case "status": {
299
- const configured = await isShipConfigured($);
300
- if (!configured) {
301
- return "Ship is not configured. Run 'ship init' first.";
302
- }
303
- return "Ship is configured in this project.";
304
- }
2486
+ // =============================================================================
2487
+ // Commands
2488
+ // =============================================================================
305
2489
 
306
- case "ready": {
307
- const result = await runShip($, ["ready", "--json"]);
308
- if (!result.success) {
309
- return `Failed to get ready tasks: ${result.output}`;
310
- }
311
- try {
312
- const tasks = JSON.parse(result.output);
313
- if (tasks.length === 0) {
314
- return "No tasks ready to work on (all tasks are either blocked or completed).";
315
- }
316
- return `Ready tasks (no blockers):\n\n${formatTaskList(tasks)}`;
317
- } catch {
318
- return result.output;
319
- }
320
- }
2490
+ const SHIP_COMMANDS = {
2491
+ ready: {
2492
+ description: "Find ready-to-work tasks with no blockers",
2493
+ template: `Use the \`ship\` tool with action \`ready\` to find tasks that are ready to work on (no blocking dependencies).
321
2494
 
322
- case "list": {
323
- const listArgs = ["list", "--json"];
324
- if (args.filter?.status) listArgs.push("--status", args.filter.status);
325
- if (args.filter?.priority) listArgs.push("--priority", args.filter.priority);
326
- if (args.filter?.mine) listArgs.push("--mine");
2495
+ Present the results in a clear format showing task ID, title, priority, and URL.
327
2496
 
328
- const result = await runShip($, listArgs);
329
- if (!result.success) {
330
- return `Failed to list tasks: ${result.output}`;
331
- }
332
- try {
333
- const tasks = JSON.parse(result.output);
334
- if (tasks.length === 0) {
335
- return "No tasks found matching the filter.";
336
- }
337
- return `Tasks:\n\n${formatTaskList(tasks)}`;
338
- } catch {
339
- return result.output;
340
- }
341
- }
2497
+ If there are ready tasks, ask the user which one they'd like to work on. If they choose one, use the \`ship\` tool with action \`start\` to begin work on it.
342
2498
 
343
- case "blocked": {
344
- const result = await runShip($, ["blocked", "--json"]);
345
- if (!result.success) {
346
- return `Failed to get blocked tasks: ${result.output}`;
347
- }
348
- try {
349
- const tasks = JSON.parse(result.output);
350
- if (tasks.length === 0) {
351
- return "No blocked tasks.";
352
- }
353
- return `Blocked tasks:\n\n${formatTaskList(tasks)}`;
354
- } catch {
355
- return result.output;
356
- }
357
- }
2499
+ If there are no ready tasks, suggest checking blocked tasks (action \`blocked\`) or creating a new task (action \`create\`).`,
2500
+ },
2501
+ };
358
2502
 
359
- case "show": {
360
- if (!args.taskId) {
361
- return "Error: taskId is required for show action";
362
- }
363
- const result = await runShip($, ["show", args.taskId, "--json"]);
364
- if (!result.success) {
365
- return `Failed to get task: ${result.output}`;
366
- }
367
- try {
368
- const task = JSON.parse(result.output);
369
- return formatTaskDetails(task);
370
- } catch {
371
- return result.output;
372
- }
373
- }
2503
+ // =============================================================================
2504
+ // Compaction Context Hooks
2505
+ // =============================================================================
374
2506
 
375
- case "start": {
376
- if (!args.taskId) {
377
- return "Error: taskId is required for start action";
378
- }
379
- const result = await runShip($, ["start", args.taskId]);
380
- if (!result.success) {
381
- return `Failed to start task: ${result.output}`;
382
- }
383
- return `Started working on ${args.taskId}`;
384
- }
2507
+ /**
2508
+ * Create the compaction context hook.
2509
+ *
2510
+ * This hook is called BEFORE compaction starts. It allows us to append
2511
+ * additional context to the compaction prompt, which will be included
2512
+ * in the generated summary.
2513
+ *
2514
+ * @param shellService - Shell service for running ship commands
2515
+ */
2516
+ const createCompactionHook = (
2517
+ shellService: ShellService,
2518
+ ): Hooks["experimental.session.compacting"] => {
2519
+ const ShellServiceLive = Layer.succeed(ShellService, shellService);
2520
+ const ShipServiceLive = Layer.effect(ShipService, makeShipService).pipe(
2521
+ Layer.provide(ShellServiceLive),
2522
+ );
385
2523
 
386
- case "done": {
387
- if (!args.taskId) {
388
- return "Error: taskId is required for done action";
389
- }
390
- const result = await runShip($, ["done", args.taskId]);
391
- if (!result.success) {
392
- return `Failed to complete task: ${result.output}`;
393
- }
394
- return `Completed ${args.taskId}`;
395
- }
2524
+ return async (input, output) => {
2525
+ const { sessionID } = input;
396
2526
 
397
- case "create": {
398
- if (!args.title) {
399
- return "Error: title is required for create action";
400
- }
401
- const createArgs = ["create", args.title, "--json"];
402
- if (args.description) createArgs.push("--description", args.description);
403
- if (args.priority) createArgs.push("--priority", args.priority);
2527
+ // Try to get the tracked task for this session
2528
+ const trackedTask = getTrackedTask(sessionID);
404
2529
 
405
- const result = await runShip($, createArgs);
406
- if (!result.success) {
407
- return `Failed to create task: ${result.output}`;
408
- }
409
- try {
410
- const task = JSON.parse(result.output);
411
- return `Created task ${task.identifier}: ${task.title}\nURL: ${task.url}`;
412
- } catch {
413
- return result.output;
414
- }
415
- }
2530
+ if (Option.isNone(trackedTask)) {
2531
+ // No task tracked for this session, nothing to preserve
2532
+ return;
2533
+ }
416
2534
 
417
- case "block": {
418
- if (!args.blocker || !args.blocked) {
419
- return "Error: both blocker and blocked task IDs are required";
420
- }
421
- const result = await runShip($, ["block", args.blocker, args.blocked]);
422
- if (!result.success) {
423
- return `Failed to add blocker: ${result.output}`;
424
- }
425
- return `${args.blocker} now blocks ${args.blocked}`;
426
- }
2535
+ const { taskId, workdir } = trackedTask.value;
427
2536
 
428
- case "unblock": {
429
- if (!args.blocker || !args.blocked) {
430
- return "Error: both blocker and blocked task IDs are required";
431
- }
432
- const result = await runShip($, ["unblock", args.blocker, args.blocked]);
433
- if (!result.success) {
434
- return `Failed to remove blocker: ${result.output}`;
435
- }
436
- return `Removed ${args.blocker} as blocker of ${args.blocked}`;
437
- }
2537
+ // Fetch task details and stack status in parallel
2538
+ const [taskResult, stackResult] = await Effect.runPromise(
2539
+ Effect.all(
2540
+ [
2541
+ Effect.gen(function* () {
2542
+ const ship = yield* ShipService;
2543
+ return yield* ship.getTask(taskId);
2544
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))),
2545
+ Effect.gen(function* () {
2546
+ const ship = yield* ShipService;
2547
+ return yield* ship.getStackStatus(workdir);
2548
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))),
2549
+ ],
2550
+ { concurrency: 2 },
2551
+ ).pipe(Effect.provide(ShipServiceLive)),
2552
+ );
438
2553
 
439
- case "prime": {
440
- const result = await runShip($, ["prime"]);
441
- if (!result.success) {
442
- return `Failed to get context: ${result.output}`;
443
- }
444
- return result.output;
445
- }
2554
+ // Build the compaction context
2555
+ const contextParts: string[] = [];
2556
+
2557
+ contextParts.push("## Ship Task Context (Preserve Across Compaction)");
2558
+ contextParts.push("");
2559
+
2560
+ if (taskResult) {
2561
+ contextParts.push(`**Current Task:** ${taskResult.identifier} - ${taskResult.title}`);
2562
+ contextParts.push(`**Status:** ${taskResult.state || taskResult.status}`);
2563
+ contextParts.push(`**Priority:** ${taskResult.priority}`);
2564
+ contextParts.push(`**URL:** ${taskResult.url}`);
2565
+ } else {
2566
+ contextParts.push(`**Current Task:** ${taskId} (details unavailable)`);
2567
+ }
446
2568
 
447
- default:
448
- return `Unknown action: ${args.action}`;
2569
+ if (workdir) {
2570
+ contextParts.push(`**Workspace:** ${workdir}`);
2571
+ }
2572
+
2573
+ if (stackResult?.change) {
2574
+ const c = stackResult.change;
2575
+ contextParts.push("");
2576
+ contextParts.push("**VCS State:**");
2577
+ contextParts.push(`- Change: ${c.changeId.slice(0, 8)}`);
2578
+ contextParts.push(`- Description: ${c.description.split("\n")[0] || "(no description)"}`);
2579
+ if (c.bookmarks.length > 0) {
2580
+ contextParts.push(`- Bookmarks: ${c.bookmarks.join(", ")}`);
449
2581
  }
450
- },
451
- });
452
- }
2582
+ }
2583
+
2584
+ contextParts.push("");
2585
+ contextParts.push("**IMPORTANT:** After compaction, immediately:");
2586
+ contextParts.push('1. Load the ship-cli skill using: `skill(name="ship-cli")`');
2587
+ contextParts.push(
2588
+ `2. Continue working on task ${taskId}${workdir ? ` in workspace ${workdir}` : ""}`,
2589
+ );
2590
+
2591
+ // Add to output context
2592
+ output.context.push(contextParts.join("\n"));
2593
+ };
2594
+ };
2595
+
2596
+ // =============================================================================
2597
+ // Tool Execute Hook
2598
+ // =============================================================================
453
2599
 
454
2600
  /**
455
- * Ship OpenCode Plugin
2601
+ * Create the tool.execute.after hook to track task state.
2602
+ *
2603
+ * This hook monitors ship tool calls to track:
2604
+ * - When tasks are started (action=start)
2605
+ * - When workspaces are created (action=stack-create)
2606
+ *
2607
+ * This state is used during compaction to preserve context.
456
2608
  */
457
- export const ShipPlugin: Plugin = async ({ client, $ }) => {
458
- const injectedSessions = new Set<string>();
2609
+ const createToolExecuteAfterHook = (): Hooks["tool.execute.after"] => {
2610
+ return async (input, output) => {
2611
+ // Only track ship tool calls
2612
+ if (input.tool !== "ship") {
2613
+ return;
2614
+ }
459
2615
 
460
- // Create the ship tool with captured $
461
- const shipTool = createShipTool($);
2616
+ const { sessionID } = input;
462
2617
 
463
- return {
464
- "chat.message": async (_input, output) => {
465
- const sessionID = output.message.sessionID;
2618
+ // Parse the args from the output metadata using Schema validation
2619
+ const argsOption = decodeShipToolArgs(output.metadata);
2620
+ if (Option.isNone(argsOption)) {
2621
+ return;
2622
+ }
2623
+ const args = argsOption.value;
466
2624
 
467
- // Skip if already injected this session
468
- if (injectedSessions.has(sessionID)) return;
2625
+ // Track task starts
2626
+ if (args.action === "start" && args.taskId) {
2627
+ trackTask(sessionID, { taskId: args.taskId });
2628
+ }
469
2629
 
470
- // Check if ship-context was already injected (handles plugin reload/reconnection)
471
- try {
472
- const existing = await client.session.messages({
473
- path: { id: sessionID },
474
- });
2630
+ // Track workspace creation (updates workdir for existing task)
2631
+ if (args.action === "stack-create") {
2632
+ // Extract workspace path from output
2633
+ const workspaceMatch = output.output.match(/Created workspace: \S+ at (.+?)(?:\n|$)/);
2634
+ const workdir = workspaceMatch?.[1];
475
2635
 
476
- if (existing.data) {
477
- const hasShipContext = existing.data.some((msg) => {
478
- const parts = (msg as any).parts || (msg.info as any).parts;
479
- if (!parts) return false;
480
- return parts.some(
481
- (part: any) =>
482
- part.type === "text" && part.text?.includes("<ship-context>")
483
- );
484
- });
2636
+ // Extract taskId from args or from existing tracked task
2637
+ const taskId = args.taskId;
2638
+ if (taskId) {
2639
+ trackTask(sessionID, { taskId, workdir });
2640
+ } else if (workdir) {
2641
+ // Just update workdir for existing tracked task
2642
+ trackTask(sessionID, { workdir });
2643
+ }
2644
+ }
485
2645
 
486
- if (hasShipContext) {
487
- injectedSessions.add(sessionID);
488
- return;
489
- }
490
- }
491
- } catch {
492
- // On error, proceed with injection
2646
+ // Track task completion - clear the tracked task
2647
+ if (args.action === "done" && args.taskId) {
2648
+ const existing = sessionTaskMap.get(sessionID);
2649
+ if (existing?.taskId === args.taskId) {
2650
+ sessionTaskMap.delete(sessionID);
493
2651
  }
2652
+ }
2653
+ };
2654
+ };
494
2655
 
495
- injectedSessions.add(sessionID);
2656
+ // =============================================================================
2657
+ // Plugin Export
2658
+ // =============================================================================
496
2659
 
497
- // Use output.message which has the resolved model/agent values
498
- await injectShipContext(client, $, sessionID, {
499
- model: output.message.model,
500
- agent: output.message.agent,
501
- });
502
- },
2660
+ // Extended PluginInput type to include serverUrl (available in OpenCode 1.0.144+)
2661
+ type ExtendedPluginInput = Parameters<Plugin>[0] & {
2662
+ serverUrl?: URL;
2663
+ };
503
2664
 
504
- event: async ({ event }) => {
505
- if (event.type === "session.compacted") {
506
- const sessionID = event.properties.sessionID;
507
- const context = await getSessionContext(client, sessionID);
508
- await injectShipContext(client, $, sessionID, context);
509
- }
510
- },
2665
+ export const ShipPlugin = async (input: ExtendedPluginInput) => {
2666
+ const { $, directory, serverUrl } = input;
2667
+ const shellService = makeShellService($, directory);
2668
+ // Convert URL object to string for passing to CLI commands
2669
+ const serverUrlString = serverUrl?.toString();
511
2670
 
512
- // Register the ship tool
2671
+ return {
2672
+ config: async (config: Parameters<NonNullable<Awaited<ReturnType<Plugin>>["config"]>>[0]) => {
2673
+ config.command = { ...config.command, ...SHIP_COMMANDS };
2674
+ },
513
2675
  tool: {
514
- ship: shipTool,
2676
+ ship: createShipTool($, directory, serverUrlString),
515
2677
  },
2678
+ "tool.execute.after": createToolExecuteAfterHook(),
2679
+ "experimental.session.compacting": createCompactionHook(shellService),
516
2680
  };
517
2681
  };
518
2682
 
519
- // Default export for OpenCode
520
2683
  export default ShipPlugin;