opencode-pilot 0.16.3 → 0.16.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -470,6 +470,8 @@ function runSpawn(args, options = {}) {
470
470
  export async function createSessionViaApi(serverUrl, directory, prompt, options = {}) {
471
471
  const fetchFn = options.fetch || fetch;
472
472
 
473
+ let session = null;
474
+
473
475
  try {
474
476
  // Step 1: Create a new session with the directory parameter
475
477
  const sessionUrl = new URL('/session', serverUrl);
@@ -486,7 +488,7 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
486
488
  throw new Error(`Failed to create session: ${createResponse.status} ${errorText}`);
487
489
  }
488
490
 
489
- const session = await createResponse.json();
491
+ session = await createResponse.json();
490
492
  debug(`createSessionViaApi: created session ${session.id} in ${directory}`);
491
493
 
492
494
  // Step 2: Update session title if provided
@@ -523,19 +525,40 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
523
525
  messageBody.modelID = modelID;
524
526
  }
525
527
 
526
- const messageResponse = await fetchFn(messageUrl.toString(), {
527
- method: 'POST',
528
- headers: { 'Content-Type': 'application/json' },
529
- body: JSON.stringify(messageBody),
530
- });
528
+ // Use AbortController with timeout for the message POST
529
+ // The /session/{id}/message endpoint returns a chunked/streaming response
530
+ // that stays open until the agent completes. We only need to verify the
531
+ // request was accepted (2xx status), not wait for the full response.
532
+ const controller = new AbortController();
533
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
531
534
 
532
- if (!messageResponse.ok) {
533
- const errorText = await messageResponse.text();
534
- throw new Error(`Failed to send message: ${messageResponse.status} ${errorText}`);
535
+ try {
536
+ const messageResponse = await fetchFn(messageUrl.toString(), {
537
+ method: 'POST',
538
+ headers: { 'Content-Type': 'application/json' },
539
+ body: JSON.stringify(messageBody),
540
+ signal: controller.signal,
541
+ });
542
+
543
+ clearTimeout(timeoutId);
544
+
545
+ if (!messageResponse.ok) {
546
+ const errorText = await messageResponse.text();
547
+ throw new Error(`Failed to send message: ${messageResponse.status} ${errorText}`);
548
+ }
549
+
550
+ debug(`createSessionViaApi: sent message to session ${session.id}`);
551
+ } catch (abortErr) {
552
+ clearTimeout(timeoutId);
553
+ // AbortError is expected - we intentionally abort after verifying the request started
554
+ // The server accepted our message, we just don't need to wait for the response
555
+ if (abortErr.name === 'AbortError') {
556
+ debug(`createSessionViaApi: message request started for session ${session.id} (response aborted as expected)`);
557
+ } else {
558
+ throw abortErr;
559
+ }
535
560
  }
536
561
 
537
- debug(`createSessionViaApi: sent message to session ${session.id}`);
538
-
539
562
  return {
540
563
  success: true,
541
564
  sessionId: session.id,
@@ -543,6 +566,17 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
543
566
  };
544
567
  } catch (err) {
545
568
  debug(`createSessionViaApi: error - ${err.message}`);
569
+ // If session was created but message failed, still return success
570
+ // to prevent re-processing (the session exists, user can send message manually)
571
+ if (session) {
572
+ debug(`createSessionViaApi: session ${session.id} was created, marking as success despite message error`);
573
+ return {
574
+ success: true,
575
+ sessionId: session.id,
576
+ directory,
577
+ warning: err.message,
578
+ };
579
+ }
546
580
  return {
547
581
  success: false,
548
582
  error: err.message,
@@ -227,9 +227,13 @@ export async function pollOnce(options = {}) {
227
227
  itemUpdatedAt: item.updated_at || null,
228
228
  });
229
229
  }
230
- console.log(`[poll] Started session for ${item.id}`);
230
+ if (result.warning) {
231
+ console.log(`[poll] Started session for ${item.id} (warning: ${result.warning})`);
232
+ } else {
233
+ console.log(`[poll] Started session for ${item.id}`);
234
+ }
231
235
  } else {
232
- console.error(`[poll] Failed to start session: ${result.stderr}`);
236
+ console.error(`[poll] Failed to start session: ${result.error || result.stderr || 'unknown error'}`);
233
237
  }
234
238
  } catch (err) {
235
239
  console.error(`[poll] Error executing action: ${err.message}`);
@@ -352,7 +352,12 @@ export function resolveRepoForItem(source, item) {
352
352
  if (resolvedRepo) {
353
353
  return source.repos.includes(resolvedRepo) ? [resolvedRepo] : [];
354
354
  }
355
- // No repo template - return empty (can't match without item context)
355
+ // No repo template - if exactly one repo, use it as default
356
+ // (e.g., Linear issues don't have repo context, user explicitly configures one repo)
357
+ if (source.repos.length === 1) {
358
+ return source.repos;
359
+ }
360
+ // Multiple repos but can't match without item context
356
361
  return [];
357
362
  }
358
363
 
@@ -930,5 +930,47 @@ describe('actions.js', () => {
930
930
  assert.strictEqual(messageBody.providerID, 'anthropic', 'Should parse provider from model');
931
931
  assert.strictEqual(messageBody.modelID, 'claude-sonnet-4-20250514', 'Should parse model ID');
932
932
  });
933
+
934
+ test('returns success with warning when session created but message fails', async () => {
935
+ const { createSessionViaApi } = await import('../../service/actions.js');
936
+
937
+ const mockSessionId = 'ses_partial123';
938
+
939
+ const mockFetch = async (url, opts) => {
940
+ const urlObj = new URL(url);
941
+
942
+ // Session creation succeeds
943
+ if (urlObj.pathname === '/session' && opts?.method === 'POST') {
944
+ return {
945
+ ok: true,
946
+ json: async () => ({ id: mockSessionId }),
947
+ };
948
+ }
949
+
950
+ // Message send fails
951
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
952
+ return {
953
+ ok: false,
954
+ status: 500,
955
+ text: async () => 'Message send failed',
956
+ };
957
+ }
958
+
959
+ return { ok: true, json: async () => ({}) };
960
+ };
961
+
962
+ const result = await createSessionViaApi(
963
+ 'http://localhost:4096',
964
+ '/path/to/project',
965
+ 'Fix the bug',
966
+ { fetch: mockFetch }
967
+ );
968
+
969
+ // Should return success because session was created
970
+ assert.ok(result.success, 'Should return success when session was created');
971
+ assert.strictEqual(result.sessionId, mockSessionId, 'Should return session ID');
972
+ assert.ok(result.warning, 'Should include warning about message failure');
973
+ assert.ok(result.warning.includes('Failed to send message'), 'Warning should mention message failure');
974
+ });
933
975
  });
934
976
  });
@@ -769,6 +769,46 @@ sources:
769
769
  assert.deepStrictEqual(resolveRepoForItem(source, filteredItem), []);
770
770
  });
771
771
 
772
+ test('single-repo allowlist uses repo as default when no template', async () => {
773
+ // Linear issues don't have repository context - when exactly one repo is configured,
774
+ // use it as the default for all items from that source
775
+ writeFileSync(configPath, `
776
+ sources:
777
+ - preset: linear/my-issues
778
+ repos:
779
+ - 0din-ai/odin
780
+ `);
781
+
782
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
783
+ loadRepoConfig(configPath);
784
+ const source = getSources()[0];
785
+
786
+ // Linear items don't have repository field
787
+ const linearItem = { id: 'linear:abc123', title: 'Fix bug', state: { name: 'In Progress' } };
788
+ assert.deepStrictEqual(resolveRepoForItem(source, linearItem), ['0din-ai/odin'],
789
+ 'single-repo allowlist should use repo as default');
790
+ });
791
+
792
+ test('multi-repo allowlist returns empty when no template match', async () => {
793
+ // With multiple repos and no way to determine which one, return empty
794
+ writeFileSync(configPath, `
795
+ sources:
796
+ - preset: linear/my-issues
797
+ repos:
798
+ - org/repo-a
799
+ - org/repo-b
800
+ `);
801
+
802
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
803
+ loadRepoConfig(configPath);
804
+ const source = getSources()[0];
805
+
806
+ // Can't determine which of the 2 repos to use
807
+ const linearItem = { id: 'linear:abc123', title: 'Fix bug' };
808
+ assert.deepStrictEqual(resolveRepoForItem(source, linearItem), [],
809
+ 'multi-repo allowlist should return empty when no template');
810
+ });
811
+
772
812
  test('github presets include semantic session names', async () => {
773
813
  writeFileSync(configPath, `
774
814
  sources: