opencode-pilot 0.24.2 → 0.24.4

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/.releaserc.cjs CHANGED
@@ -22,7 +22,15 @@ module.exports = {
22
22
  // Publish to npm with provenance
23
23
  ['@semantic-release/npm', { provenance: true }],
24
24
 
25
- // Create GitHub release (creates the tag/tarball)
25
+ // Commit version bump to package.json + package-lock.json back to repo
26
+ // Runs in 'prepare' phase AFTER npm bumps package.json, BEFORE GitHub creates the tag
27
+ // This ensures the GitHub tarball includes the correct version
28
+ ['@semantic-release/git', {
29
+ assets: ['package.json', 'package-lock.json'],
30
+ message: 'chore(release): ${nextRelease.version} [skip ci]'
31
+ }],
32
+
33
+ // Create GitHub release (creates the tag/tarball from the version-bumped commit)
26
34
  '@semantic-release/github',
27
35
 
28
36
  // Update Homebrew formula with new version and SHA (runs after release is created)
package/CONTRIBUTING.md CHANGED
@@ -87,5 +87,6 @@ Releases are automated via [semantic-release](https://github.com/semantic-releas
87
87
  1. Run tests
88
88
  2. Determine the next version from commit messages
89
89
  3. Publish to npm
90
- 4. Create a GitHub release
91
- 5. Update the Homebrew formula
90
+ 4. Commit version bump (`package.json`, `package-lock.json`) back to the repo
91
+ 5. Create a GitHub release
92
+ 6. Update the Homebrew formula
@@ -1,8 +1,8 @@
1
1
  class OpencodePilot < Formula
2
2
  desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
3
3
  homepage "https://github.com/athal7/opencode-pilot"
4
- url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.1.tar.gz"
5
- sha256 "ea5cb01e8893bdebc24b7868659355c1762e6672bfc50b2cec775f0431381adb"
4
+ url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.3.tar.gz"
5
+ sha256 "39b5b418b5af1c3731de56f549303b0af55591b5730522802d95267724889f6f"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "node"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.24.2",
3
+ "version": "0.24.4",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "devDependencies": {
33
33
  "@semantic-release/exec": "^7.1.0",
34
+ "@semantic-release/git": "^10.0.1",
34
35
  "@semantic-release/github": "^9.2.6",
35
36
  "@semantic-release/npm": "^13.1.0",
36
37
  "semantic-release": "^25.0.2"
@@ -14,6 +14,12 @@ import { resolveWorktreeDirectory, getProjectInfo, getProjectInfoForDirectory }
14
14
  import path from "path";
15
15
  import os from "os";
16
16
 
17
+ // Safety timeout for the server to return HTTP response headers.
18
+ // The /command endpoint can take 30-45s to return headers because it does
19
+ // work before responding. The /message endpoint returns headers in ~1ms.
20
+ // These are generous upper bounds — if exceeded, the server is genuinely stuck.
21
+ export const HEADER_TIMEOUT_MS = 60_000;
22
+
17
23
  /**
18
24
  * Parse a slash command from the beginning of a prompt
19
25
  * Returns null if the prompt doesn't start with a slash command
@@ -519,6 +525,7 @@ export function selectBestSession(sessions, statuses) {
519
525
  */
520
526
  export async function sendMessageToSession(serverUrl, sessionId, directory, prompt, options = {}) {
521
527
  const fetchFn = options.fetch || fetch;
528
+ const headerTimeout = options.headerTimeout || HEADER_TIMEOUT_MS;
522
529
 
523
530
  try {
524
531
  // Step 1: Update session title if provided
@@ -536,9 +543,16 @@ export async function sendMessageToSession(serverUrl, sessionId, directory, prom
536
543
  // Step 2: Check if the prompt starts with a slash command
537
544
  const parsedCommand = parseSlashCommand(prompt);
538
545
 
539
- // Use AbortController with timeout (same pattern as createSessionViaApi)
546
+ // Wait for response headers (confirming server accepted the request),
547
+ // then abort the body stream. Safety timeout catches stuck requests.
540
548
  const controller = new AbortController();
541
- const timeoutId = setTimeout(() => controller.abort(), 10000);
549
+ let headersReceived = false;
550
+ const timeoutId = setTimeout(() => {
551
+ if (!headersReceived) {
552
+ debug(`sendMessageToSession: safety timeout - server did not return headers within ${headerTimeout}ms for session ${sessionId}`);
553
+ controller.abort();
554
+ }
555
+ }, headerTimeout);
542
556
 
543
557
  try {
544
558
  let response;
@@ -580,6 +594,8 @@ export async function sendMessageToSession(serverUrl, sessionId, directory, prom
580
594
  });
581
595
  }
582
596
 
597
+ // Headers received — cancel the safety timeout
598
+ headersReceived = true;
583
599
  clearTimeout(timeoutId);
584
600
 
585
601
  if (!response.ok) {
@@ -587,14 +603,17 @@ export async function sendMessageToSession(serverUrl, sessionId, directory, prom
587
603
  throw new Error(`Failed to send ${parsedCommand ? 'command' : 'message'}: ${response.status} ${errorText}`);
588
604
  }
589
605
 
590
- debug(`sendMessageToSession: sent ${parsedCommand ? 'command' : 'message'} to session ${sessionId}`);
606
+ debug(`sendMessageToSession: ${parsedCommand ? 'command' : 'message'} accepted by server for session ${sessionId}`);
591
607
  } catch (abortErr) {
592
608
  clearTimeout(timeoutId);
593
- if (abortErr.name === 'AbortError') {
594
- debug(`sendMessageToSession: request started for session ${sessionId} (response aborted as expected)`);
595
- } else {
596
- throw abortErr;
609
+ if (abortErr.name === 'AbortError' && !headersReceived) {
610
+ throw new Error(`Server did not confirm acceptance within ${headerTimeout / 1000}s for session ${sessionId}`);
597
611
  }
612
+ throw abortErr;
613
+ } finally {
614
+ // Abort the body stream — we don't need the streaming response content.
615
+ // Done in finally to ensure cleanup regardless of success/error path.
616
+ controller.abort();
598
617
  }
599
618
 
600
619
  return {
@@ -670,6 +689,7 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
670
689
  */
671
690
  export async function createSessionViaApi(serverUrl, directory, prompt, options = {}) {
672
691
  const fetchFn = options.fetch || fetch;
692
+ const headerTimeout = options.headerTimeout || HEADER_TIMEOUT_MS;
673
693
 
674
694
  let session = null;
675
695
 
@@ -706,11 +726,18 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
706
726
  // Step 3: Check if the prompt starts with a slash command
707
727
  const parsedCommand = parseSlashCommand(prompt);
708
728
 
709
- // Use AbortController with timeout for the request
710
- // The endpoints return a chunked/streaming response that stays open until
711
- // the agent completes. We only need to verify the request was accepted.
729
+ // Wait for the server to return response headers (confirming it accepted the
730
+ // request), then abort the body stream. The /command endpoint can take 30-45s
731
+ // to return headers we must NOT abort before that or we can't tell if the
732
+ // server actually accepted. A generous safety timeout catches truly stuck requests.
712
733
  const controller = new AbortController();
713
- const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
734
+ let headersReceived = false;
735
+ const timeoutId = setTimeout(() => {
736
+ if (!headersReceived) {
737
+ debug(`createSessionViaApi: safety timeout - server did not return headers within ${headerTimeout}ms for session ${session.id}`);
738
+ controller.abort();
739
+ }
740
+ }, headerTimeout);
714
741
 
715
742
  try {
716
743
  let response;
@@ -755,6 +782,8 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
755
782
  });
756
783
  }
757
784
 
785
+ // Headers received — cancel the safety timeout
786
+ headersReceived = true;
758
787
  clearTimeout(timeoutId);
759
788
 
760
789
  if (!response.ok) {
@@ -762,16 +791,19 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
762
791
  throw new Error(`Failed to send ${parsedCommand ? 'command' : 'message'}: ${response.status} ${errorText}`);
763
792
  }
764
793
 
765
- debug(`createSessionViaApi: sent ${parsedCommand ? 'command' : 'message'} to session ${session.id}`);
794
+ debug(`createSessionViaApi: ${parsedCommand ? 'command' : 'message'} accepted by server for session ${session.id}`);
766
795
  } catch (abortErr) {
767
796
  clearTimeout(timeoutId);
768
- // AbortError is expected - we intentionally abort after verifying the request started
769
- // The server accepted our request, we just don't need to wait for the response
770
- if (abortErr.name === 'AbortError') {
771
- debug(`createSessionViaApi: request started for session ${session.id} (response aborted as expected)`);
772
- } else {
773
- throw abortErr;
797
+ if (abortErr.name === 'AbortError' && !headersReceived) {
798
+ // Safety timeout fired before headers arrived.
799
+ // The server may or may not have accepted the request — we don't know.
800
+ throw new Error(`Server did not confirm acceptance within ${headerTimeout / 1000}s for session ${session.id}`);
774
801
  }
802
+ throw abortErr;
803
+ } finally {
804
+ // Abort the body stream — we don't need the streaming response content.
805
+ // Done in finally to ensure cleanup regardless of success/error path.
806
+ controller.abort();
775
807
  }
776
808
 
777
809
  return {
@@ -903,6 +935,7 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}) {
903
935
  sessionId: result.sessionId,
904
936
  directory: cwd,
905
937
  error: result.error,
938
+ warning: result.warning,
906
939
  };
907
940
  }
908
941
 
@@ -1263,6 +1263,103 @@ Check for bugs and security issues.`;
1263
1263
  });
1264
1264
  });
1265
1265
 
1266
+ describe('createSessionViaApi timeout handling', () => {
1267
+ test('treats safety timeout as error with warning (not silent success)', async () => {
1268
+ const { createSessionViaApi } = await import('../../service/actions.js');
1269
+
1270
+ const mockSessionId = 'ses_timeout123';
1271
+
1272
+ const mockFetch = async (url, opts) => {
1273
+ const urlObj = new URL(url);
1274
+
1275
+ // Session creation succeeds
1276
+ if (urlObj.pathname === '/session' && opts?.method === 'POST') {
1277
+ return {
1278
+ ok: true,
1279
+ json: async () => ({ id: mockSessionId }),
1280
+ };
1281
+ }
1282
+
1283
+ // Title update succeeds
1284
+ if (opts?.method === 'PATCH') {
1285
+ return { ok: true, json: async () => ({}) };
1286
+ }
1287
+
1288
+ // Message endpoint: simulate server never returning headers
1289
+ // by waiting until the AbortSignal fires
1290
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1291
+ return new Promise((resolve, reject) => {
1292
+ if (opts.signal) {
1293
+ opts.signal.addEventListener('abort', () => {
1294
+ reject(new DOMException('The operation was aborted.', 'AbortError'));
1295
+ });
1296
+ }
1297
+ });
1298
+ }
1299
+
1300
+ return { ok: false, text: async () => 'Not found' };
1301
+ };
1302
+
1303
+ const result = await createSessionViaApi(
1304
+ 'http://localhost:4096',
1305
+ '/path/to/project',
1306
+ 'Fix the bug',
1307
+ { fetch: mockFetch, title: 'Test Session', headerTimeout: 100 }
1308
+ );
1309
+
1310
+ // Should return success (session was created) but WITH a warning
1311
+ assert.ok(result.success, 'Should return success because session was created');
1312
+ assert.strictEqual(result.sessionId, mockSessionId, 'Should return session ID');
1313
+ assert.ok(result.warning, 'Should include warning about timeout');
1314
+ assert.ok(result.warning.includes('did not confirm acceptance'), 'Warning should mention acceptance timeout');
1315
+ });
1316
+
1317
+ test('aborts body stream after receiving 200 headers', async () => {
1318
+ const { createSessionViaApi } = await import('../../service/actions.js');
1319
+
1320
+ const mockSessionId = 'ses_headers123';
1321
+ let bodyAborted = false;
1322
+
1323
+ const mockFetch = async (url, opts) => {
1324
+ const urlObj = new URL(url);
1325
+
1326
+ if (urlObj.pathname === '/session' && opts?.method === 'POST') {
1327
+ return {
1328
+ ok: true,
1329
+ json: async () => ({ id: mockSessionId }),
1330
+ };
1331
+ }
1332
+
1333
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1334
+ // Track when the signal aborts (should happen after we return 200)
1335
+ if (opts.signal) {
1336
+ opts.signal.addEventListener('abort', () => {
1337
+ bodyAborted = true;
1338
+ });
1339
+ }
1340
+ // Return 200 immediately (simulating headers received)
1341
+ return {
1342
+ ok: true,
1343
+ json: async () => ({ success: true }),
1344
+ };
1345
+ }
1346
+
1347
+ return { ok: false, text: async () => 'Not found' };
1348
+ };
1349
+
1350
+ const result = await createSessionViaApi(
1351
+ 'http://localhost:4096',
1352
+ '/path/to/project',
1353
+ 'Fix the bug',
1354
+ { fetch: mockFetch }
1355
+ );
1356
+
1357
+ assert.ok(result.success, 'Should succeed');
1358
+ assert.ok(!result.warning, 'Should NOT have a warning');
1359
+ assert.ok(bodyAborted, 'Should have aborted the body stream after receiving headers');
1360
+ });
1361
+ });
1362
+
1266
1363
  describe('sendMessageToSession slash command routing', () => {
1267
1364
  test('uses /command endpoint for slash commands', async () => {
1268
1365
  const { sendMessageToSession } = await import('../../service/actions.js');