opencode-pilot 0.7.3 → 0.9.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/README.md CHANGED
@@ -19,9 +19,9 @@ npm install -g opencode-pilot
19
19
 
20
20
  ## Quick Start
21
21
 
22
- 1. **Create config** - Copy [examples/config.yaml](examples/config.yaml) to `~/.config/opencode-pilot/config.yaml` and customize
22
+ 1. **Create config** - Copy [examples/config.yaml](examples/config.yaml) to `~/.config/opencode/pilot/config.yaml` and customize
23
23
 
24
- 2. **Create templates** - Add prompt templates to `~/.config/opencode-pilot/templates/`
24
+ 2. **Create templates** - Add prompt templates to `~/.config/opencode/pilot/templates/`
25
25
 
26
26
  3. **Enable the plugin** - Add to your `opencode.json`:
27
27
 
@@ -67,7 +67,7 @@ Three ways to configure sources, from simplest to most flexible:
67
67
 
68
68
  ### Prompt Templates
69
69
 
70
- Create prompt templates as markdown files in `~/.config/opencode-pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
70
+ Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
71
71
 
72
72
  ## CLI Commands
73
73
 
@@ -42,6 +42,8 @@ sources:
42
42
  # completed: false
43
43
  # item:
44
44
  # id: "reminder:{id}"
45
+ # session:
46
+ # name: "{title}" # Optional: custom session name (presets have semantic defaults)
45
47
 
46
48
  # Tool config for custom MCP servers (GitHub/Linear have built-in config)
47
49
  # tools:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.7.3",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -5,17 +5,139 @@
5
5
  * Supports prompt_template for custom prompts (e.g., to invoke /devcontainer).
6
6
  */
7
7
 
8
- import { spawn } from "child_process";
8
+ import { spawn, execSync } from "child_process";
9
9
  import { readFileSync, existsSync } from "fs";
10
10
  import { debug } from "./logger.js";
11
11
  import { getNestedValue } from "./utils.js";
12
12
  import path from "path";
13
13
  import os from "os";
14
14
 
15
+ /**
16
+ * Get running opencode server ports by parsing lsof output
17
+ * @returns {Promise<number[]>} Array of port numbers
18
+ */
19
+ async function getOpencodePorts() {
20
+ try {
21
+ const output = execSync('lsof -i -P 2>/dev/null | grep -E "opencode.*LISTEN" || true', {
22
+ encoding: 'utf-8',
23
+ timeout: 30000
24
+ });
25
+
26
+ const ports = [];
27
+ for (const line of output.split('\n')) {
28
+ // Parse lines like: opencode- 6897 athal 12u IPv4 ... TCP *:60993 (LISTEN)
29
+ const match = line.match(/:(\d+)\s+\(LISTEN\)/);
30
+ if (match) {
31
+ ports.push(parseInt(match[1], 10));
32
+ }
33
+ }
34
+ return ports;
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Check if targetPath is within or equal to worktree path
42
+ * @param {string} targetPath - The path we're looking for
43
+ * @param {string} worktree - The server's worktree path
44
+ * @param {string[]} sandboxes - Array of sandbox paths
45
+ * @returns {number} Match score (higher = better match, 0 = no match)
46
+ */
47
+ function getPathMatchScore(targetPath, worktree, sandboxes = []) {
48
+ // Normalize paths
49
+ const normalizedTarget = path.resolve(targetPath);
50
+ const normalizedWorktree = path.resolve(worktree);
51
+
52
+ // Exact sandbox match (highest priority)
53
+ for (const sandbox of sandboxes) {
54
+ const normalizedSandbox = path.resolve(sandbox);
55
+ if (normalizedTarget === normalizedSandbox || normalizedTarget.startsWith(normalizedSandbox + path.sep)) {
56
+ return normalizedSandbox.length + 1000; // Bonus for sandbox match
57
+ }
58
+ }
59
+
60
+ // Exact worktree match
61
+ if (normalizedTarget === normalizedWorktree) {
62
+ return normalizedWorktree.length + 500; // Bonus for exact match
63
+ }
64
+
65
+ // Target is subdirectory of worktree
66
+ if (normalizedTarget.startsWith(normalizedWorktree + path.sep)) {
67
+ return normalizedWorktree.length;
68
+ }
69
+
70
+ // Global project (worktree = "/") matches everything with lowest priority
71
+ if (normalizedWorktree === '/') {
72
+ return 1;
73
+ }
74
+
75
+ return 0; // No match
76
+ }
77
+
78
+ /**
79
+ * Discover a running opencode server that matches the target directory
80
+ *
81
+ * Queries all running opencode servers and finds the best match based on:
82
+ * 1. Exact sandbox match (highest priority)
83
+ * 2. Exact worktree match
84
+ * 3. Target is subdirectory of worktree
85
+ * 4. Global project (worktree="/") as fallback
86
+ *
87
+ * @param {string} targetDir - The directory we want to work in
88
+ * @param {object} [options] - Options for testing/mocking
89
+ * @param {function} [options.getPorts] - Function to get server ports
90
+ * @param {function} [options.fetch] - Function to fetch URLs
91
+ * @returns {Promise<string|null>} Server URL (e.g., "http://localhost:4096") or null
92
+ */
93
+ export async function discoverOpencodeServer(targetDir, options = {}) {
94
+ const getPorts = options.getPorts || getOpencodePorts;
95
+ const fetchFn = options.fetch || fetch;
96
+
97
+ const ports = await getPorts();
98
+ if (ports.length === 0) {
99
+ debug('discoverOpencodeServer: no servers found');
100
+ return null;
101
+ }
102
+
103
+ debug(`discoverOpencodeServer: checking ${ports.length} servers for ${targetDir}`);
104
+
105
+ let bestMatch = null;
106
+ let bestScore = 0;
107
+
108
+ for (const port of ports) {
109
+ const url = `http://localhost:${port}`;
110
+ try {
111
+ const response = await fetchFn(`${url}/project/current`);
112
+ if (!response.ok) {
113
+ debug(`discoverOpencodeServer: ${url} returned ${response.status}`);
114
+ continue;
115
+ }
116
+
117
+ const project = await response.json();
118
+ const worktree = project.worktree || '/';
119
+ const sandboxes = project.sandboxes || [];
120
+
121
+ const score = getPathMatchScore(targetDir, worktree, sandboxes);
122
+ debug(`discoverOpencodeServer: ${url} worktree=${worktree} score=${score}`);
123
+
124
+ if (score > bestScore) {
125
+ bestScore = score;
126
+ bestMatch = url;
127
+ }
128
+ } catch (err) {
129
+ debug(`discoverOpencodeServer: ${url} error: ${err.message}`);
130
+ }
131
+ }
132
+
133
+ debug(`discoverOpencodeServer: best match=${bestMatch} score=${bestScore}`);
134
+ return bestMatch;
135
+ }
136
+
15
137
  // Default templates directory
16
138
  const DEFAULT_TEMPLATES_DIR = path.join(
17
139
  os.homedir(),
18
- ".config/opencode-pilot/templates"
140
+ ".config/opencode/pilot/templates"
19
141
  );
20
142
 
21
143
  /**
@@ -109,9 +231,10 @@ export function getActionConfig(source, repoConfig, defaults) {
109
231
  * @param {object} item - Item to create session for
110
232
  * @param {object} config - Merged action config
111
233
  * @param {string} [templatesDir] - Templates directory path (for testing)
234
+ * @param {string} [serverUrl] - URL of running opencode server to attach to
112
235
  * @returns {object} { args: string[], cwd: string }
113
236
  */
114
- export function getCommandInfoNew(item, config, templatesDir) {
237
+ export function getCommandInfoNew(item, config, templatesDir, serverUrl) {
115
238
  // Determine working directory: working_dir > path > repo_path > home
116
239
  const workingDir = config.working_dir || config.path || config.repo_path || "~";
117
240
  const cwd = expandPath(workingDir);
@@ -119,11 +242,16 @@ export function getCommandInfoNew(item, config, templatesDir) {
119
242
  // Build session name
120
243
  const sessionName = config.session?.name
121
244
  ? buildSessionName(config.session.name, item)
122
- : `session-${item.number || item.id || Date.now()}`;
245
+ : (item.title || `session-${Date.now()}`);
123
246
 
124
247
  // Build command args
125
248
  const args = ["opencode", "run"];
126
249
 
250
+ // Add --attach if server URL is provided
251
+ if (serverUrl) {
252
+ args.push("--attach", serverUrl);
253
+ }
254
+
127
255
  // Add session title
128
256
  args.push("--title", sessionName);
129
257
 
@@ -169,13 +297,14 @@ function buildPrompt(item, config) {
169
297
  /**
170
298
  * Build command args for action
171
299
  * Uses "opencode run" for non-interactive execution
300
+ * @deprecated Legacy function - not currently used. See getCommandInfoNew instead.
172
301
  * @returns {object} { args: string[], cwd: string }
173
302
  */
174
303
  function buildCommandArgs(item, config) {
175
304
  const repoPath = expandPath(config.repo_path || ".");
176
305
  const sessionTitle = config.session?.name_template
177
306
  ? buildSessionName(config.session.name_template, item)
178
- : `issue-${item.number || Date.now()}`;
307
+ : (item.title || `session-${Date.now()}`);
179
308
 
180
309
  // Build opencode run command args array (non-interactive)
181
310
  // Note: --title sets session title (--session is for continuing existing sessions)
@@ -264,11 +393,29 @@ function runSpawn(args, options = {}) {
264
393
  * @param {object} config - Repo config with action settings
265
394
  * @param {object} [options] - Execution options
266
395
  * @param {boolean} [options.dryRun] - If true, return command without executing
396
+ * @param {function} [options.discoverServer] - Custom server discovery function (for testing)
267
397
  * @returns {Promise<object>} Result with command, stdout, stderr, exitCode
268
398
  */
269
399
  export async function executeAction(item, config, options = {}) {
270
- const cmdInfo = getCommandInfo(item, config);
271
- const command = buildCommand(item, config); // For logging/display
400
+ // Get working directory first to determine which server to attach to
401
+ const workingDir = config.working_dir || config.path || config.repo_path || "~";
402
+ const cwd = expandPath(workingDir);
403
+
404
+ // Discover running opencode server for this directory
405
+ const discoverFn = options.discoverServer || discoverOpencodeServer;
406
+ const serverUrl = await discoverFn(cwd);
407
+
408
+ debug(`executeAction: discovered server=${serverUrl} for cwd=${cwd}`);
409
+
410
+ // Build command info with server URL for --attach flag
411
+ const cmdInfo = getCommandInfoNew(item, config, undefined, serverUrl);
412
+
413
+ // Build command string for display
414
+ const quoteArgs = (args) => args.map(a =>
415
+ a.includes(" ") || a.includes("\n") ? `"${a.replace(/"/g, '\\"')}"` : a
416
+ ).join(" ");
417
+ const cmdStr = quoteArgs(cmdInfo.args);
418
+ const command = cmdInfo.cwd ? `(cd ${cmdInfo.cwd} && ${cmdStr})` : cmdStr;
272
419
 
273
420
  debug(`executeAction: command=${command}`);
274
421
  debug(`executeAction: args=${JSON.stringify(cmdInfo.args)}, cwd=${cmdInfo.cwd}`);
package/service/poller.js CHANGED
@@ -282,7 +282,7 @@ export async function pollGenericSource(source, options = {}) {
282
282
  * @returns {object} Poller instance
283
283
  */
284
284
  export function createPoller(options = {}) {
285
- const stateFile = options.stateFile || path.join(os.homedir(), '.config/opencode-pilot/poll-state.json');
285
+ const stateFile = options.stateFile || path.join(os.homedir(), '.config/opencode/pilot/poll-state.json');
286
286
  const configPath = options.configPath;
287
287
 
288
288
  // Load existing state
@@ -22,6 +22,8 @@ my-issues:
22
22
  item:
23
23
  id: "{html_url}"
24
24
  repo: "{repository_full_name}"
25
+ session:
26
+ name: "{title}"
25
27
 
26
28
  review-requests:
27
29
  name: review-requests
@@ -33,6 +35,8 @@ review-requests:
33
35
  item:
34
36
  id: "{html_url}"
35
37
  repo: "{repository_full_name}"
38
+ session:
39
+ name: "Review: {title}"
36
40
 
37
41
  my-prs-feedback:
38
42
  name: my-prs-feedback
@@ -46,6 +50,8 @@ my-prs-feedback:
46
50
  item:
47
51
  id: "{html_url}"
48
52
  repo: "{repository_full_name}"
53
+ session:
54
+ name: "Feedback: {title}"
49
55
  # Reprocess when PR is updated (new commits pushed, new comments, etc.)
50
56
  # This ensures we re-trigger after addressing review feedback
51
57
  reprocess_on:
@@ -21,3 +21,5 @@ my-issues:
21
21
  # teamId and assigneeId are required - user must provide
22
22
  item:
23
23
  id: "linear:{id}"
24
+ session:
25
+ name: "{title}"
@@ -20,13 +20,13 @@ import { expandPreset, expandGitHubShorthand, getProviderConfig } from "./preset
20
20
  // Default config path
21
21
  const DEFAULT_CONFIG_PATH = path.join(
22
22
  os.homedir(),
23
- ".config/opencode-pilot/config.yaml"
23
+ ".config/opencode/pilot/config.yaml"
24
24
  );
25
25
 
26
26
  // Default templates directory
27
27
  const DEFAULT_TEMPLATES_DIR = path.join(
28
28
  os.homedir(),
29
- ".config/opencode-pilot/templates"
29
+ ".config/opencode/pilot/templates"
30
30
  );
31
31
 
32
32
  // In-memory config cache (for testing and runtime)
package/service/server.js CHANGED
@@ -13,7 +13,7 @@ import YAML from 'yaml'
13
13
 
14
14
  // Default configuration
15
15
  const DEFAULT_HTTP_PORT = 4097
16
- const DEFAULT_REPOS_CONFIG = join(homedir(), '.config', 'opencode-pilot', 'config.yaml')
16
+ const DEFAULT_REPOS_CONFIG = join(homedir(), '.config', 'opencode', 'pilot', 'config.yaml')
17
17
  const DEFAULT_POLL_INTERVAL = 5 * 60 * 1000 // 5 minutes
18
18
 
19
19
  /**
@@ -231,5 +231,267 @@ describe('actions.js', () => {
231
231
  assert.ok(lastArg.includes('/devcontainer issue-66'), 'Prompt should include /devcontainer command');
232
232
  assert.ok(lastArg.includes('Fix bug'), 'Prompt should include title');
233
233
  });
234
+
235
+ test('includes --attach when serverUrl is provided', async () => {
236
+ const { getCommandInfoNew } = await import('../../service/actions.js');
237
+
238
+ const item = { number: 123, title: 'Fix bug' };
239
+ const config = {
240
+ path: '~/code/backend',
241
+ prompt: 'default'
242
+ };
243
+
244
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir, 'http://localhost:4096');
245
+
246
+ assert.ok(cmdInfo.args.includes('--attach'), 'Should include --attach flag');
247
+ assert.ok(cmdInfo.args.includes('http://localhost:4096'), 'Should include server URL');
248
+ });
249
+
250
+ test('does not include --attach when serverUrl is null', async () => {
251
+ const { getCommandInfoNew } = await import('../../service/actions.js');
252
+
253
+ const item = { number: 123, title: 'Fix bug' };
254
+ const config = {
255
+ path: '~/code/backend',
256
+ prompt: 'default'
257
+ };
258
+
259
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir, null);
260
+
261
+ assert.ok(!cmdInfo.args.includes('--attach'), 'Should not include --attach flag');
262
+ });
263
+
264
+ test('uses item title as session name when no session.name configured', async () => {
265
+ const { getCommandInfoNew } = await import('../../service/actions.js');
266
+
267
+ const item = { id: 'reminder-123', title: 'Review quarterly reports' };
268
+ const config = {
269
+ path: '~/code/backend',
270
+ prompt: 'default'
271
+ };
272
+
273
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir);
274
+
275
+ const titleIndex = cmdInfo.args.indexOf('--title');
276
+ assert.ok(titleIndex !== -1, 'Should have --title flag');
277
+ assert.strictEqual(cmdInfo.args[titleIndex + 1], 'Review quarterly reports', 'Should use item title as session name');
278
+ });
279
+
280
+ test('falls back to timestamp when no session.name and no title', async () => {
281
+ const { getCommandInfoNew } = await import('../../service/actions.js');
282
+
283
+ const item = { id: 'item-123' };
284
+ const config = {
285
+ path: '~/code/backend',
286
+ prompt: 'default'
287
+ };
288
+
289
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir);
290
+
291
+ const titleIndex = cmdInfo.args.indexOf('--title');
292
+ assert.ok(titleIndex !== -1, 'Should have --title flag');
293
+ assert.ok(cmdInfo.args[titleIndex + 1].startsWith('session-'), 'Should fall back to session-{timestamp}');
294
+ });
295
+ });
296
+
297
+ describe('discoverOpencodeServer', () => {
298
+ test('returns null when no servers running', async () => {
299
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
300
+
301
+ // Mock with empty port list
302
+ const result = await discoverOpencodeServer('/some/path', { getPorts: async () => [] });
303
+
304
+ assert.strictEqual(result, null);
305
+ });
306
+
307
+ test('returns matching server URL for exact worktree match', async () => {
308
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
309
+
310
+ const mockPorts = async () => [3000, 4000];
311
+ const mockFetch = async (url) => {
312
+ if (url === 'http://localhost:3000/project/current') {
313
+ return { ok: true, json: async () => ({ worktree: '/Users/test/project-a', sandboxes: [] }) };
314
+ }
315
+ if (url === 'http://localhost:4000/project/current') {
316
+ return { ok: true, json: async () => ({ worktree: '/Users/test/project-b', sandboxes: [] }) };
317
+ }
318
+ return { ok: false };
319
+ };
320
+
321
+ const result = await discoverOpencodeServer('/Users/test/project-b', {
322
+ getPorts: mockPorts,
323
+ fetch: mockFetch
324
+ });
325
+
326
+ assert.strictEqual(result, 'http://localhost:4000');
327
+ });
328
+
329
+ test('returns matching server URL for subdirectory of worktree', async () => {
330
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
331
+
332
+ const mockPorts = async () => [3000];
333
+ const mockFetch = async (url) => {
334
+ if (url === 'http://localhost:3000/project/current') {
335
+ return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
336
+ }
337
+ return { ok: false };
338
+ };
339
+
340
+ const result = await discoverOpencodeServer('/Users/test/project/src/components', {
341
+ getPorts: mockPorts,
342
+ fetch: mockFetch
343
+ });
344
+
345
+ assert.strictEqual(result, 'http://localhost:3000');
346
+ });
347
+
348
+ test('returns matching server URL when cwd is in sandboxes', async () => {
349
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
350
+
351
+ const mockPorts = async () => [3000];
352
+ const mockFetch = async (url) => {
353
+ if (url === 'http://localhost:3000/project/current') {
354
+ return { ok: true, json: async () => ({
355
+ worktree: '/Users/test/project',
356
+ sandboxes: ['/Users/test/.opencode/worktree/abc/sandbox-1']
357
+ }) };
358
+ }
359
+ return { ok: false };
360
+ };
361
+
362
+ const result = await discoverOpencodeServer('/Users/test/.opencode/worktree/abc/sandbox-1', {
363
+ getPorts: mockPorts,
364
+ fetch: mockFetch
365
+ });
366
+
367
+ assert.strictEqual(result, 'http://localhost:3000');
368
+ });
369
+
370
+ test('prefers more specific worktree match over less specific', async () => {
371
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
372
+
373
+ const mockPorts = async () => [3000, 4000];
374
+ const mockFetch = async (url) => {
375
+ if (url === 'http://localhost:3000/project/current') {
376
+ // Global project
377
+ return { ok: true, json: async () => ({ worktree: '/', sandboxes: [] }) };
378
+ }
379
+ if (url === 'http://localhost:4000/project/current') {
380
+ // Specific project
381
+ return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
382
+ }
383
+ return { ok: false };
384
+ };
385
+
386
+ const result = await discoverOpencodeServer('/Users/test/project/src', {
387
+ getPorts: mockPorts,
388
+ fetch: mockFetch
389
+ });
390
+
391
+ // Should prefer the more specific match (port 4000)
392
+ assert.strictEqual(result, 'http://localhost:4000');
393
+ });
394
+
395
+ test('falls back to global project when no specific match', async () => {
396
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
397
+
398
+ const mockPorts = async () => [3000];
399
+ const mockFetch = async (url) => {
400
+ if (url === 'http://localhost:3000/project/current') {
401
+ return { ok: true, json: async () => ({ worktree: '/', sandboxes: [] }) };
402
+ }
403
+ return { ok: false };
404
+ };
405
+
406
+ const result = await discoverOpencodeServer('/Users/test/random/path', {
407
+ getPorts: mockPorts,
408
+ fetch: mockFetch
409
+ });
410
+
411
+ assert.strictEqual(result, 'http://localhost:3000');
412
+ });
413
+
414
+ test('returns null when fetch fails for all servers', async () => {
415
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
416
+
417
+ const mockPorts = async () => [3000, 4000];
418
+ const mockFetch = async () => {
419
+ throw new Error('Connection refused');
420
+ };
421
+
422
+ const result = await discoverOpencodeServer('/some/path', {
423
+ getPorts: mockPorts,
424
+ fetch: mockFetch
425
+ });
426
+
427
+ assert.strictEqual(result, null);
428
+ });
429
+
430
+ test('skips servers that return non-ok response', async () => {
431
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
432
+
433
+ const mockPorts = async () => [3000, 4000];
434
+ const mockFetch = async (url) => {
435
+ if (url === 'http://localhost:3000/project/current') {
436
+ return { ok: false };
437
+ }
438
+ if (url === 'http://localhost:4000/project/current') {
439
+ return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
440
+ }
441
+ return { ok: false };
442
+ };
443
+
444
+ const result = await discoverOpencodeServer('/Users/test/project', {
445
+ getPorts: mockPorts,
446
+ fetch: mockFetch
447
+ });
448
+
449
+ assert.strictEqual(result, 'http://localhost:4000');
450
+ });
451
+ });
452
+
453
+ describe('executeAction', () => {
454
+ test('discovers server and includes --attach in dry run', async () => {
455
+ const { executeAction } = await import('../../service/actions.js');
456
+
457
+ const item = { number: 123, title: 'Fix bug' };
458
+ const config = {
459
+ path: tempDir,
460
+ prompt: 'default'
461
+ };
462
+
463
+ // Mock server discovery
464
+ const mockDiscoverServer = async () => 'http://localhost:4096';
465
+
466
+ const result = await executeAction(item, config, {
467
+ dryRun: true,
468
+ discoverServer: mockDiscoverServer
469
+ });
470
+
471
+ assert.ok(result.dryRun);
472
+ assert.ok(result.command.includes('--attach'), 'Command should include --attach flag');
473
+ assert.ok(result.command.includes('http://localhost:4096'), 'Command should include server URL');
474
+ });
475
+
476
+ test('does not include --attach when no server discovered', async () => {
477
+ const { executeAction } = await import('../../service/actions.js');
478
+
479
+ const item = { number: 123, title: 'Fix bug' };
480
+ const config = {
481
+ path: tempDir,
482
+ prompt: 'default'
483
+ };
484
+
485
+ // Mock no server found
486
+ const mockDiscoverServer = async () => null;
487
+
488
+ const result = await executeAction(item, config, {
489
+ dryRun: true,
490
+ discoverServer: mockDiscoverServer
491
+ });
492
+
493
+ assert.ok(result.dryRun);
494
+ assert.ok(!result.command.includes('--attach'), 'Command should not include --attach flag');
495
+ });
234
496
  });
235
497
  });
@@ -664,6 +664,44 @@ sources:
664
664
  const filteredItem = { repository_full_name: 'other/repo' };
665
665
  assert.deepStrictEqual(resolveRepoForItem(source, filteredItem), []);
666
666
  });
667
+
668
+ test('github presets include semantic session names', async () => {
669
+ writeFileSync(configPath, `
670
+ sources:
671
+ - preset: github/my-issues
672
+ - preset: github/review-requests
673
+ - preset: github/my-prs-feedback
674
+ `);
675
+
676
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
677
+ loadRepoConfig(configPath);
678
+ const sources = getSources();
679
+
680
+ // my-issues: just the title
681
+ assert.strictEqual(sources[0].session.name, '{title}', 'my-issues should use title');
682
+
683
+ // review-requests: "Review: {title}"
684
+ assert.strictEqual(sources[1].session.name, 'Review: {title}', 'review-requests should prefix with Review:');
685
+
686
+ // my-prs-feedback: "Feedback: {title}"
687
+ assert.strictEqual(sources[2].session.name, 'Feedback: {title}', 'my-prs-feedback should prefix with Feedback:');
688
+ });
689
+
690
+ test('linear preset includes session name', async () => {
691
+ writeFileSync(configPath, `
692
+ sources:
693
+ - preset: linear/my-issues
694
+ args:
695
+ teamId: "team-uuid"
696
+ assigneeId: "user-uuid"
697
+ `);
698
+
699
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
700
+ loadRepoConfig(configPath);
701
+ const sources = getSources();
702
+
703
+ assert.strictEqual(sources[0].session.name, '{title}', 'linear/my-issues should use title');
704
+ });
667
705
  });
668
706
 
669
707
  describe('shorthand syntax', () => {