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 +9 -1
- package/CONTRIBUTING.md +3 -2
- package/Formula/opencode-pilot.rb +2 -2
- package/package.json +2 -1
- package/service/actions.js +51 -18
- package/test/unit/actions.test.js +97 -0
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
|
-
//
|
|
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.
|
|
91
|
-
5.
|
|
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.
|
|
5
|
-
sha256 "
|
|
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.
|
|
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"
|
package/service/actions.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
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
|
-
|
|
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:
|
|
794
|
+
debug(`createSessionViaApi: ${parsedCommand ? 'command' : 'message'} accepted by server for session ${session.id}`);
|
|
766
795
|
} catch (abortErr) {
|
|
767
796
|
clearTimeout(timeoutId);
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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');
|