opencode-pilot 0.23.0 → 0.24.1

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.
@@ -5,7 +5,7 @@
5
5
  "ghcr.io/devcontainers/features/git:1": {},
6
6
  "ghcr.io/devcontainers/features/github-cli:1": {}
7
7
  },
8
- "postCreateCommand": "sudo apt-get update && sudo apt-get install -y bats ripgrep && npm install -g opencode-ai@latest",
8
+ "postCreateCommand": "sudo apt-get update && sudo apt-get install -y ripgrep && npm install -g opencode-ai@latest",
9
9
  "customizations": {
10
10
  "vscode": {
11
11
  "extensions": [
@@ -29,7 +29,7 @@ jobs:
29
29
 
30
30
  - name: Verify JavaScript syntax
31
31
  run: |
32
- for file in plugin/*.js; do
32
+ for file in plugin/*.js service/*.js; do
33
33
  echo "Checking $file..."
34
34
  node --check "$file"
35
35
  done
package/CONTRIBUTING.md CHANGED
@@ -1,4 +1,4 @@
1
- # Contributing to opencode-ntfy
1
+ # Contributing to opencode-pilot
2
2
 
3
3
  Thanks for your interest in contributing!
4
4
 
@@ -6,84 +6,75 @@ Thanks for your interest in contributing!
6
6
 
7
7
  1. Clone the repository:
8
8
  ```bash
9
- git clone https://github.com/athal7/opencode-ntfy.git
10
- cd opencode-ntfy
9
+ git clone https://github.com/athal7/opencode-pilot.git
10
+ cd opencode-pilot
11
11
  ```
12
12
 
13
- 2. Install the plugin locally for testing:
13
+ 2. Install dependencies:
14
14
  ```bash
15
- ./install.sh
16
- ```
17
-
18
- 3. Set required environment variables:
19
- ```bash
20
- export NTFY_TOPIC=your-test-topic
15
+ npm install
21
16
  ```
22
17
 
23
18
  ## Running Tests
24
19
 
25
- Run the full test suite:
26
20
  ```bash
27
- ./test/run_tests.bash
21
+ npm test # Unit tests
22
+ npm run test:integration # Integration tests
23
+ npm run test:all # All tests
28
24
  ```
29
25
 
30
- The test suite includes:
31
- - **File structure tests** - Verify all plugin files exist
32
- - **Syntax validation** - Run `node --check` on all JS files
33
- - **Export structure tests** - Verify expected functions are exported
34
- - **Integration tests** - Test plugin loads in OpenCode without hanging (requires opencode CLI)
26
+ Tests use the Node.js built-in test runner (`node:test`) with `node:assert`.
35
27
 
36
28
  ## Writing Tests
37
29
 
38
- Tests live in `test/` using bash test helpers from `test_helper.bash`.
30
+ Tests live in `test/unit/` and `test/integration/`. Each test file follows this pattern:
39
31
 
40
- Example test:
41
- ```bash
42
- test_my_feature() {
43
- # Use assertions from test_helper.bash
44
- assert_file_exists "$PLUGIN_DIR/myfile.js"
45
- assert_contains "$output" "expected string"
46
- }
47
-
48
- # Register and run
49
- run_test "my_feature" "test_my_feature"
50
- ```
32
+ ```js
33
+ import { test, describe } from 'node:test';
34
+ import assert from 'node:assert';
51
35
 
52
- For JavaScript unit tests, use Node.js inline:
53
- ```bash
54
- test_function_works() {
55
- node --input-type=module -e "
56
- import { myFunction } from '../plugin/module.js';
57
- if (myFunction() !== 'expected') throw new Error('Failed');
58
- console.log('PASS');
59
- " || return 1
60
- }
36
+ describe('myModule', () => {
37
+ test('does the thing', () => {
38
+ assert.strictEqual(actual, expected);
39
+ });
40
+ });
61
41
  ```
62
42
 
63
43
  ## Code Style
64
44
 
65
45
  - Use ES modules (`import`/`export`)
66
46
  - Use `async`/`await` for async operations
67
- - Log with `[opencode-ntfy]` prefix
47
+ - Log with `[opencode-pilot]` prefix
68
48
  - Handle errors gracefully (log, don't crash OpenCode)
69
- - No external dependencies (use Node.js built-ins only)
49
+ - No external dependencies beyond what's in `package.json`
70
50
 
71
- ## Plugin Architecture
51
+ ## Project Architecture
72
52
 
73
53
  ```
74
54
  plugin/
75
- ├── index.js # Main entry point, event handlers
76
- ├── notifier.js # ntfy HTTP client
77
- ├── callback.js # HTTP callback server for interactive responses
78
- ├── hostname.js # Callback host discovery (Tailscale, env, localhost)
79
- └── nonces.js # Single-use nonces for callback authentication
55
+ └── index.js # OpenCode plugin entry point (auto-starts daemon)
56
+ service/
57
+ ├── server.js # HTTP server and polling orchestration
58
+ ├── poll-service.js # Polling lifecycle management
59
+ ├── poller.js # MCP tool polling
60
+ ├── actions.js # Session creation and template expansion
61
+ ├── readiness.js # Evaluate item readiness (labels, deps, priority)
62
+ ├── worktree.js # Git worktree management
63
+ ├── repo-config.js # Repository discovery and config
64
+ ├── logger.js # Debug logging
65
+ ├── utils.js # Shared utilities
66
+ ├── version.js # Package version detection
67
+ └── presets/
68
+ ├── index.js # Preset loader
69
+ ├── github.yaml # GitHub source presets
70
+ └── linear.yaml # Linear source presets
80
71
  ```
81
72
 
82
73
  ## Submitting Changes
83
74
 
84
75
  1. Create a feature branch: `git checkout -b my-feature`
85
76
  2. Make your changes
86
- 3. Run tests: `./test/run_tests.bash`
77
+ 3. Run tests: `npm test`
87
78
  4. Commit with a clear message following conventional commits:
88
79
  - `feat(#1): add idle notifications`
89
80
  - `fix(#3): handle network timeout`
@@ -91,12 +82,10 @@ plugin/
91
82
 
92
83
  ## Releasing
93
84
 
94
- Releases are automated via GitHub Actions. To create a release:
95
-
96
- 1. Tag the commit: `git tag v1.0.0`
97
- 2. Push the tag: `git push origin v1.0.0`
85
+ Releases are automated via [semantic-release](https://github.com/semantic-release/semantic-release) on merge to `main`. The CI pipeline will:
98
86
 
99
- The release workflow will:
100
87
  1. Run tests
101
- 2. Create a tarball
102
- 3. Create a GitHub release with release notes
88
+ 2. Determine the next version from commit messages
89
+ 3. Publish to npm
90
+ 4. Create a GitHub release
91
+ 5. 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.22.0.tar.gz"
5
- sha256 "b4437190cab6bff8c03ab93b6d70c8de7ccb3e396decc36b460d53f3ee482dde"
4
+ url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.23.1.tar.gz"
5
+ sha256 "106172281e27a0847f1c457837c503aa64058908ab4ac5df6fb7326db4c07079"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "node"
package/README.md CHANGED
@@ -140,6 +140,18 @@ sources:
140
140
  - `worktree_name` - Template for naming new worktrees (only with `worktree: "new"`)
141
141
  - `prefer_existing_sandbox: false` - Disable sandbox reuse for this source
142
142
 
143
+ ### Stacked PR Support
144
+
145
+ When `detect_stacks: true` is set on a source, pilot detects stacked PRs (where one PR's head branch is another PR's base branch) and reuses the existing session from a stack sibling. This gives the agent full context about the entire stack without redundant context-gathering.
146
+
147
+ ```yaml
148
+ sources:
149
+ - preset: github/review-requests
150
+ detect_stacks: true # Enabled by default in this preset
151
+ ```
152
+
153
+ The `github/review-requests` and `github/my-prs-attention` presets enable stack detection by default.
154
+
143
155
  ## CLI Commands
144
156
 
145
157
  ```bash
@@ -35,6 +35,8 @@ sources:
35
35
  - preset: github/my-issues
36
36
  prompt: worktree
37
37
 
38
+ # PR presets have detect_stacks: true by default, enabling session reuse
39
+ # across stacked PRs (where one PR's head branch = another's base branch)
38
40
  - preset: github/review-requests
39
41
  # Per-source model override (takes precedence over defaults.model)
40
42
  # model: anthropic/claude-haiku-3.5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -819,6 +819,37 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}) {
819
819
  ? buildSessionName(config.session.name, item)
820
820
  : (item.title || `session-${Date.now()}`);
821
821
 
822
+ // Check if we should reuse a stack sibling's session
823
+ // This is set when another PR in the same stack was already processed
824
+ if (config.reuse_stack_session && !options.dryRun) {
825
+ try {
826
+ debug(`executeInDirectory: trying stack session reuse ${config.reuse_stack_session} for ${cwd}`);
827
+
828
+ const stackResult = await sendMessageToSession(serverUrl, config.reuse_stack_session, cwd, prompt, {
829
+ title: sessionTitle,
830
+ agent: config.agent,
831
+ model: config.model,
832
+ fetch: options.fetch,
833
+ });
834
+
835
+ if (stackResult.success) {
836
+ const stackCommand = `[API] POST ${serverUrl}/session/${config.reuse_stack_session}/message (stack reuse)`;
837
+ return {
838
+ command: stackCommand,
839
+ success: true,
840
+ sessionId: stackResult.sessionId,
841
+ directory: cwd,
842
+ sessionReused: true,
843
+ error: stackResult.error,
844
+ };
845
+ }
846
+
847
+ debug(`executeInDirectory: stack session reuse failed, falling through`);
848
+ } catch (err) {
849
+ debug(`executeInDirectory: stack session reuse error, falling through: ${err.message}`);
850
+ }
851
+ }
852
+
822
853
  // Check if we should try to reuse an existing session
823
854
  const reuseActiveSession = config.reuse_active_session !== false; // default true
824
855
 
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig, resolveRepoForItem, getCleanupTtlDays, getStartupDelay } from "./repo-config.js";
13
- import { createPoller, pollGenericSource, enrichItemsWithComments, enrichItemsWithMergeable, computeAttentionLabels, computeDedupKeys } from "./poller.js";
13
+ import { createPoller, pollGenericSource, enrichItemsWithComments, enrichItemsWithMergeable, enrichItemsWithBranchRefs, computeAttentionLabels, computeDedupKeys, detectStacks } from "./poller.js";
14
14
  import { evaluateReadiness, sortByPriority } from "./readiness.js";
15
15
  import { executeAction, buildCommand } from "./actions.js";
16
16
  import { debug } from "./logger.js";
@@ -146,6 +146,12 @@ export async function pollOnce(options = {}) {
146
146
  debug(`Enriched ${items.length} items with mergeable status`);
147
147
  }
148
148
 
149
+ // Enrich items with branch refs for stack detection if configured
150
+ if (source.detect_stacks) {
151
+ items = await enrichItemsWithBranchRefs(items, source);
152
+ debug(`Enriched ${items.length} items with branch refs for stack detection`);
153
+ }
154
+
149
155
  // Compute attention labels if both enrichments are present (for my-prs-attention)
150
156
  if (source.enrich_mergeable && source.filter_bot_comments) {
151
157
  items = computeAttentionLabels(items, source);
@@ -193,6 +199,12 @@ export async function pollOnce(options = {}) {
193
199
  const sortConfig = readyItems.length > 0 ? readyItems[0]._repoConfig : {};
194
200
  const sortedItems = sortByPriority(readyItems, sortConfig);
195
201
 
202
+ // Detect PR stacks for session reuse (only when detect_stacks is enabled)
203
+ const stackMap = source.detect_stacks ? detectStacks(sortedItems) : new Map();
204
+ if (stackMap.size > 0) {
205
+ debug(`Detected ${stackMap.size} items in PR stacks`);
206
+ }
207
+
196
208
  // Process ready items
197
209
  // Get reprocess_on config: source-level overrides provider-level
198
210
  const reprocessOn = source.reprocess_on || toolProviderConfig?.reprocess_on;
@@ -241,6 +253,21 @@ export async function pollOnce(options = {}) {
241
253
  debug(`Reusing existing directory: ${existingDirectory}`);
242
254
  }
243
255
 
256
+ // Check if a stack sibling was already processed (for session reuse across stacked PRs)
257
+ if (stackMap.has(item.id) && pollerInstance) {
258
+ const siblings = stackMap.get(item.id);
259
+ for (const siblingId of siblings) {
260
+ const siblingMeta = pollerInstance.getProcessedMeta(siblingId);
261
+ if (siblingMeta?.sessionId && siblingMeta?.directory) {
262
+ actionConfig.existing_directory = siblingMeta.directory;
263
+ actionConfig.reuse_stack_session = siblingMeta.sessionId;
264
+ debug(`Stack reuse: ${item.id} reusing session ${siblingMeta.sessionId} from sibling ${siblingId}`);
265
+ console.log(`[poll] Stack reuse: ${item.id} reusing session from stack sibling ${siblingId}`);
266
+ break;
267
+ }
268
+ }
269
+ }
270
+
244
271
  // Skip items with no valid local path (prevents sessions in home directory)
245
272
  const hasLocalPath = actionConfig.working_dir || actionConfig.path || actionConfig.repo_path;
246
273
  if (!hasLocalPath) {
@@ -277,8 +304,12 @@ export async function pollOnce(options = {}) {
277
304
  command: result.command,
278
305
  source: sourceName,
279
306
  directory: result.directory || null,
307
+ sessionId: result.sessionId || null,
280
308
  itemState: item.state || item.status || null,
281
309
  itemUpdatedAt: item.updated_at || null,
310
+ // Store attention state for detecting new feedback on PRs
311
+ // _has_attention is boolean for enriched items, undefined for non-PR sources
312
+ hasAttention: item._has_attention ?? null,
282
313
  dedupKeys: dedupKeys.length > 0 ? dedupKeys : undefined,
283
314
  });
284
315
  }
package/service/poller.js CHANGED
@@ -635,6 +635,167 @@ export async function enrichItemsWithMergeable(items, source, options = {}) {
635
635
  return enrichedItems;
636
636
  }
637
637
 
638
+ /**
639
+ * Fetch branch ref names for a PR via gh CLI
640
+ *
641
+ * @param {string} owner - Repository owner
642
+ * @param {string} repo - Repository name
643
+ * @param {number} number - PR number
644
+ * @param {number} timeout - Timeout in ms
645
+ * @returns {Promise<object|null>} { headRefName, baseRefName } or null on error
646
+ */
647
+ async function fetchBranchRefs(owner, repo, number, timeout) {
648
+ const { exec } = await import('child_process');
649
+ const { promisify } = await import('util');
650
+ const execAsync = promisify(exec);
651
+
652
+ try {
653
+ const { stdout } = await Promise.race([
654
+ execAsync(`gh pr view ${number} -R ${owner}/${repo} --json headRefName,baseRefName`),
655
+ createTimeout(timeout, "gh pr view branch refs"),
656
+ ]);
657
+
658
+ const data = JSON.parse(stdout.trim());
659
+ return data && data.headRefName && data.baseRefName ? data : null;
660
+ } catch (err) {
661
+ console.error(`[poller] Error fetching branch refs for ${owner}/${repo}#${number}: ${err.message}`);
662
+ return null;
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Enrich items with branch ref names for stack detection
668
+ *
669
+ * For items from sources with detect_stacks: true, fetches headRefName and
670
+ * baseRefName via gh CLI and attaches them as _headRefName and _baseRefName
671
+ * fields for stack detection.
672
+ *
673
+ * @param {Array} items - Items to enrich
674
+ * @param {object} source - Source configuration with optional detect_stacks
675
+ * @param {object} [options] - Options
676
+ * @param {number} [options.timeout] - Timeout in ms (default: 30000)
677
+ * @returns {Promise<Array>} Items with _headRefName and _baseRefName fields added
678
+ */
679
+ export async function enrichItemsWithBranchRefs(items, source, options = {}) {
680
+ // Skip if not configured or not a GitHub source
681
+ if (!source.detect_stacks || !isGitHubSource(source)) {
682
+ return items;
683
+ }
684
+
685
+ const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
686
+
687
+ // Fetch branch refs for each item
688
+ const enrichedItems = [];
689
+ for (const item of items) {
690
+ // Extract owner/repo from item
691
+ const fullName = item.repository_full_name || item.repository?.nameWithOwner;
692
+ if (!fullName || !item.number) {
693
+ enrichedItems.push(item);
694
+ continue;
695
+ }
696
+
697
+ const [owner, repo] = fullName.split("/");
698
+ const refs = await fetchBranchRefs(owner, repo, item.number, timeout);
699
+ if (refs) {
700
+ enrichedItems.push({ ...item, _headRefName: refs.headRefName, _baseRefName: refs.baseRefName });
701
+ } else {
702
+ enrichedItems.push(item);
703
+ }
704
+ }
705
+
706
+ return enrichedItems;
707
+ }
708
+
709
+ /**
710
+ * Detect PR stacks from enriched items
711
+ *
712
+ * Groups items by repo and finds stacks by matching headRefName/baseRefName:
713
+ * if PR A's headRefName equals PR B's baseRefName, they're in the same stack.
714
+ * Walks chains to handle 3+ PR stacks.
715
+ *
716
+ * @param {Array} items - Items enriched with _headRefName and _baseRefName
717
+ * @returns {Map<string, string[]>} Map of itemId -> sibling itemIds (only stacked items included)
718
+ */
719
+ export function detectStacks(items) {
720
+ const stacks = new Map();
721
+
722
+ if (!items || items.length === 0) return stacks;
723
+
724
+ // Group items by repo (stacks only make sense within same repo)
725
+ const byRepo = new Map();
726
+ for (const item of items) {
727
+ if (!item._headRefName || !item._baseRefName) continue;
728
+
729
+ const repo = item.repository_full_name || item.repository?.nameWithOwner;
730
+ if (!repo) continue;
731
+
732
+ if (!byRepo.has(repo)) {
733
+ byRepo.set(repo, []);
734
+ }
735
+ byRepo.get(repo).push(item);
736
+ }
737
+
738
+ // For each repo group, find stacks
739
+ for (const [, repoItems] of byRepo) {
740
+ if (repoItems.length < 2) continue;
741
+
742
+ // Build lookup: headRefName -> item
743
+ const headToItem = new Map();
744
+ for (const item of repoItems) {
745
+ headToItem.set(item._headRefName, item);
746
+ }
747
+
748
+ // Find connected components (stacks) using union-find approach
749
+ // Two items are connected if one's headRefName equals the other's baseRefName
750
+ const parent = new Map(); // itemId -> root itemId
751
+
752
+ function find(id) {
753
+ if (!parent.has(id)) parent.set(id, id);
754
+ if (parent.get(id) !== id) {
755
+ parent.set(id, find(parent.get(id)));
756
+ }
757
+ return parent.get(id);
758
+ }
759
+
760
+ function union(a, b) {
761
+ const rootA = find(a);
762
+ const rootB = find(b);
763
+ if (rootA !== rootB) {
764
+ parent.set(rootA, rootB);
765
+ }
766
+ }
767
+
768
+ // Connect items that form a stack
769
+ for (const item of repoItems) {
770
+ const baseMatch = headToItem.get(item._baseRefName);
771
+ if (baseMatch && baseMatch.id !== item.id) {
772
+ union(item.id, baseMatch.id);
773
+ }
774
+ }
775
+
776
+ // Group items by their root to find stack members
777
+ const groups = new Map(); // root -> [itemIds]
778
+ for (const item of repoItems) {
779
+ if (!parent.has(item.id)) continue;
780
+ const root = find(item.id);
781
+ if (!groups.has(root)) {
782
+ groups.set(root, []);
783
+ }
784
+ groups.get(root).push(item.id);
785
+ }
786
+
787
+ // Build sibling map for groups with 2+ members
788
+ for (const [, members] of groups) {
789
+ if (members.length < 2) continue;
790
+ for (const id of members) {
791
+ stacks.set(id, members.filter(m => m !== id));
792
+ }
793
+ }
794
+ }
795
+
796
+ return stacks;
797
+ }
798
+
638
799
  /**
639
800
  * Compute attention label from enriched item conditions
640
801
  *
@@ -1090,6 +1251,18 @@ export function createPoller(options = {}) {
1090
1251
  }
1091
1252
  }
1092
1253
  }
1254
+
1255
+ // Handle attention field (detect new feedback on PRs)
1256
+ // Only reprocess when attention changes from false to true
1257
+ if (field === 'attention') {
1258
+ const storedHasAttention = meta.hasAttention;
1259
+ const currentHasAttention = item._has_attention;
1260
+
1261
+ // Only trigger if we have stored state (not legacy items) and attention changed false -> true
1262
+ if (storedHasAttention === false && currentHasAttention === true) {
1263
+ return true;
1264
+ }
1265
+ }
1093
1266
  }
1094
1267
 
1095
1268
  return false;
@@ -39,6 +39,8 @@ review-requests:
39
39
  worktree_name: "pr-{number}"
40
40
  session:
41
41
  name: "Review: {title}"
42
+ # Detect stacked PRs and reuse sessions across the stack
43
+ detect_stacks: true
42
44
 
43
45
  my-prs-attention:
44
46
  name: my-prs-attention
@@ -56,10 +58,13 @@ my-prs-attention:
56
58
  # Enrich with both mergeable status and comments to detect all attention conditions
57
59
  enrich_mergeable: true
58
60
  filter_bot_comments: true
61
+ # Detect stacked PRs and reuse sessions across the stack
62
+ detect_stacks: true
59
63
  readiness:
60
64
  # Require at least one attention condition (conflicts or human feedback)
61
65
  require_attention: true
62
- # Only reprocess when state changes (e.g., reopened)
66
+ # Reprocess when state changes (reopened) or new feedback received
63
67
  # Note: updatedAt is NOT included - CI status changes would trigger reprocessing
64
68
  reprocess_on:
65
69
  - state
70
+ - attention
@@ -733,3 +733,177 @@ describe("integration: cross-source deduplication", () => {
733
733
  }
734
734
  });
735
735
  });
736
+
737
+ describe("integration: stacked PR session reuse", () => {
738
+ let mockServer;
739
+
740
+ afterEach(async () => {
741
+ if (mockServer) {
742
+ await mockServer.close();
743
+ mockServer = null;
744
+ }
745
+ });
746
+
747
+ it("reuses stack sibling's session when reuse_stack_session is set", async () => {
748
+ let sessionCreated = false;
749
+ let messageSessionId = null;
750
+ let messageTitleUpdated = null;
751
+
752
+ mockServer = await createMockServer({
753
+ "GET /session": () => ({
754
+ // Return the stack sibling's session
755
+ body: [{ id: "ses_stack_sibling", directory: "/wt/pr-101", time: { created: 1000, updated: 2000 } }],
756
+ }),
757
+ "GET /session/status": () => ({ body: {} }),
758
+ "POST /session": () => {
759
+ sessionCreated = true;
760
+ return { body: { id: "ses_new" } };
761
+ },
762
+ "PATCH /session/ses_stack_sibling": (req) => {
763
+ messageTitleUpdated = req.body?.title;
764
+ return { body: {} };
765
+ },
766
+ "POST /session/ses_stack_sibling/message": (req) => {
767
+ messageSessionId = "ses_stack_sibling";
768
+ return { body: { success: true } };
769
+ },
770
+ "GET /project/current": () => ({
771
+ body: { id: "proj_1", worktree: "/proj", time: { created: 1000, updated: 2000 }, sandboxes: [] },
772
+ }),
773
+ });
774
+
775
+ const result = await executeAction(
776
+ { number: 102, title: "Part 2 of feature" },
777
+ {
778
+ path: "/proj",
779
+ prompt: "default",
780
+ // These are set by poll-service when a stack sibling is found
781
+ existing_directory: "/wt/pr-101",
782
+ reuse_stack_session: "ses_stack_sibling",
783
+ },
784
+ { discoverServer: async () => mockServer.url }
785
+ );
786
+
787
+ assert.ok(result.success, "Action should succeed");
788
+ assert.strictEqual(result.sessionReused, true, "Should indicate session was reused");
789
+ assert.strictEqual(sessionCreated, false, "Should NOT create new session");
790
+ assert.strictEqual(messageSessionId, "ses_stack_sibling", "Should post to stack sibling's session");
791
+ });
792
+
793
+ it("falls back to normal flow when stack session is gone", async () => {
794
+ let sessionCreated = false;
795
+ let newSessionMessageId = null;
796
+
797
+ mockServer = await createMockServer({
798
+ "GET /session": () => ({
799
+ // No sessions exist (sibling's session was archived/gone)
800
+ body: [],
801
+ }),
802
+ "GET /session/status": () => ({ body: {} }),
803
+ // Stack session reuse will try this and fail
804
+ "PATCH /session/ses_gone": () => ({
805
+ status: 404,
806
+ body: { error: "Session not found" },
807
+ }),
808
+ "POST /session/ses_gone/message": () => ({
809
+ status: 404,
810
+ body: { error: "Session not found" },
811
+ }),
812
+ // Falls through to creating a new session
813
+ "POST /session": () => {
814
+ sessionCreated = true;
815
+ return { body: { id: "ses_new_fallback" } };
816
+ },
817
+ "PATCH /session/ses_new_fallback": () => ({ body: {} }),
818
+ "POST /session/ses_new_fallback/message": (req) => {
819
+ newSessionMessageId = "ses_new_fallback";
820
+ return { body: { success: true } };
821
+ },
822
+ "GET /project/current": () => ({
823
+ body: { id: "proj_1", worktree: "/proj", time: { created: 1000, updated: 2000 }, sandboxes: [] },
824
+ }),
825
+ });
826
+
827
+ const result = await executeAction(
828
+ { number: 102, title: "Part 2 of feature" },
829
+ {
830
+ path: "/proj",
831
+ prompt: "default",
832
+ existing_directory: "/wt/pr-101",
833
+ reuse_stack_session: "ses_gone",
834
+ },
835
+ { discoverServer: async () => mockServer.url }
836
+ );
837
+
838
+ assert.ok(result.success, "Action should succeed via fallback");
839
+ assert.ok(sessionCreated, "Should create new session when stack session is gone");
840
+ });
841
+
842
+ it("detectStacks + poller metadata enables stack reuse across poll cycles", async () => {
843
+ // This test verifies the full flow:
844
+ // 1. PR #101 was processed in a previous poll cycle (has sessionId + directory in metadata)
845
+ // 2. PR #102 is in the same stack (detected via detectStacks)
846
+ // 3. poll-service should set reuse_stack_session from sibling metadata
847
+
848
+ const { createPoller, detectStacks } = await import("../../service/poller.js");
849
+ const { mkdtempSync, rmSync: rmSyncFs } = await import("fs");
850
+ const { join } = await import("path");
851
+ const { tmpdir } = await import("os");
852
+
853
+ const tempDir = mkdtempSync(join(tmpdir(), "stack-reuse-test-"));
854
+ const stateFile = join(tempDir, "poll-state.json");
855
+
856
+ try {
857
+ const poller = createPoller({ stateFile });
858
+
859
+ // Simulate: PR #101 was processed in a previous poll cycle
860
+ poller.markProcessed("https://github.com/myorg/app/pull/101", {
861
+ source: "review-requests",
862
+ directory: "/wt/pr-101",
863
+ sessionId: "ses_pr101",
864
+ });
865
+
866
+ // Current poll returns both PRs with branch refs
867
+ const items = [
868
+ {
869
+ id: "https://github.com/myorg/app/pull/101",
870
+ number: 101,
871
+ repository_full_name: "myorg/app",
872
+ _baseRefName: "main",
873
+ _headRefName: "feature-part-1",
874
+ },
875
+ {
876
+ id: "https://github.com/myorg/app/pull/102",
877
+ number: 102,
878
+ repository_full_name: "myorg/app",
879
+ _baseRefName: "feature-part-1",
880
+ _headRefName: "feature-part-2",
881
+ },
882
+ ];
883
+
884
+ // Detect stacks
885
+ const stackMap = detectStacks(items);
886
+
887
+ assert.ok(stackMap.has(items[1].id), "PR #102 should be in a stack");
888
+
889
+ // Simulate what poll-service does: look up sibling metadata
890
+ const siblings = stackMap.get(items[1].id);
891
+ let foundSessionId = null;
892
+ let foundDirectory = null;
893
+
894
+ for (const siblingId of siblings) {
895
+ const meta = poller.getProcessedMeta(siblingId);
896
+ if (meta?.sessionId && meta?.directory) {
897
+ foundSessionId = meta.sessionId;
898
+ foundDirectory = meta.directory;
899
+ break;
900
+ }
901
+ }
902
+
903
+ assert.strictEqual(foundSessionId, "ses_pr101", "Should find PR #101's session ID");
904
+ assert.strictEqual(foundDirectory, "/wt/pr-101", "Should find PR #101's directory");
905
+ } finally {
906
+ rmSyncFs(tempDir, { recursive: true, force: true });
907
+ }
908
+ });
909
+ });
@@ -792,6 +792,126 @@ describe('poller.js', () => {
792
792
  // Poll 3: item-2 reappears - wasUnseen flag should still be true until cleared
793
793
  // (The flag gets cleared when shouldReprocess triggers reprocessing)
794
794
  });
795
+
796
+ test('shouldReprocess returns true when attention changes from false to true', async () => {
797
+ const { createPoller } = await import('../../service/poller.js');
798
+
799
+ const poller = createPoller({ stateFile });
800
+ // Item was processed without attention (no feedback)
801
+ poller.markProcessed('pr-1', {
802
+ source: 'my-prs-attention',
803
+ itemState: 'open',
804
+ hasAttention: false
805
+ });
806
+
807
+ // Item now has attention (received feedback)
808
+ const item = { id: 'pr-1', state: 'open', _has_attention: true };
809
+ assert.strictEqual(
810
+ poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
811
+ true,
812
+ 'Should reprocess when attention changes false -> true'
813
+ );
814
+ });
815
+
816
+ test('shouldReprocess returns false when attention stays true', async () => {
817
+ const { createPoller } = await import('../../service/poller.js');
818
+
819
+ const poller = createPoller({ stateFile });
820
+ // Item was processed with attention
821
+ poller.markProcessed('pr-1', {
822
+ source: 'my-prs-attention',
823
+ itemState: 'open',
824
+ hasAttention: true
825
+ });
826
+
827
+ // Item still has attention
828
+ const item = { id: 'pr-1', state: 'open', _has_attention: true };
829
+ assert.strictEqual(
830
+ poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
831
+ false,
832
+ 'Should NOT reprocess when attention stays true'
833
+ );
834
+ });
835
+
836
+ test('shouldReprocess returns false when attention stays false', async () => {
837
+ const { createPoller } = await import('../../service/poller.js');
838
+
839
+ const poller = createPoller({ stateFile });
840
+ // Item was processed without attention
841
+ poller.markProcessed('pr-1', {
842
+ source: 'my-prs-attention',
843
+ itemState: 'open',
844
+ hasAttention: false
845
+ });
846
+
847
+ // Item still has no attention
848
+ const item = { id: 'pr-1', state: 'open', _has_attention: false };
849
+ assert.strictEqual(
850
+ poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
851
+ false,
852
+ 'Should NOT reprocess when attention stays false'
853
+ );
854
+ });
855
+
856
+ test('shouldReprocess returns false when attention changes from true to false', async () => {
857
+ const { createPoller } = await import('../../service/poller.js');
858
+
859
+ const poller = createPoller({ stateFile });
860
+ // Item was processed with attention
861
+ poller.markProcessed('pr-1', {
862
+ source: 'my-prs-attention',
863
+ itemState: 'open',
864
+ hasAttention: true
865
+ });
866
+
867
+ // Attention was addressed (no longer needs attention)
868
+ const item = { id: 'pr-1', state: 'open', _has_attention: false };
869
+ assert.strictEqual(
870
+ poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
871
+ false,
872
+ 'Should NOT reprocess when attention changes true -> false'
873
+ );
874
+ });
875
+
876
+ test('shouldReprocess handles attention with no stored hasAttention (legacy)', async () => {
877
+ const { createPoller } = await import('../../service/poller.js');
878
+
879
+ const poller = createPoller({ stateFile });
880
+ // Legacy item without hasAttention stored
881
+ poller.markProcessed('pr-1', {
882
+ source: 'my-prs-attention',
883
+ itemState: 'open'
884
+ // Note: no hasAttention
885
+ });
886
+
887
+ // Item now has attention
888
+ const item = { id: 'pr-1', state: 'open', _has_attention: true };
889
+ // Should NOT reprocess - we don't know previous state, assume it was handled
890
+ assert.strictEqual(
891
+ poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
892
+ false,
893
+ 'Should NOT reprocess legacy items without stored hasAttention'
894
+ );
895
+ });
896
+
897
+ test('shouldReprocess handles attention combined with state changes', async () => {
898
+ const { createPoller } = await import('../../service/poller.js');
899
+
900
+ const poller = createPoller({ stateFile });
901
+ poller.markProcessed('pr-1', {
902
+ source: 'my-prs-attention',
903
+ itemState: 'closed', // Was closed
904
+ hasAttention: false
905
+ });
906
+
907
+ // Reopened but no attention - state change should trigger
908
+ const item = { id: 'pr-1', state: 'open', _has_attention: false };
909
+ assert.strictEqual(
910
+ poller.shouldReprocess(item, { reprocessOn: ['state', 'attention'] }),
911
+ true,
912
+ 'Should reprocess when state changes even if attention unchanged'
913
+ );
914
+ });
795
915
  });
796
916
 
797
917
  describe('pollGenericSource', () => {
@@ -1438,4 +1558,261 @@ describe('poller.js', () => {
1438
1558
  assert.deepStrictEqual(result, []);
1439
1559
  });
1440
1560
  });
1561
+
1562
+ describe('detectStacks', () => {
1563
+ test('detects a simple 2-PR stack', async () => {
1564
+ const { detectStacks } = await import('../../service/poller.js');
1565
+
1566
+ const items = [
1567
+ {
1568
+ id: 'https://github.com/myorg/app/pull/101',
1569
+ number: 101,
1570
+ repository_full_name: 'myorg/app',
1571
+ _baseRefName: 'main',
1572
+ _headRefName: 'feature-part-1',
1573
+ },
1574
+ {
1575
+ id: 'https://github.com/myorg/app/pull/102',
1576
+ number: 102,
1577
+ repository_full_name: 'myorg/app',
1578
+ _baseRefName: 'feature-part-1',
1579
+ _headRefName: 'feature-part-2',
1580
+ },
1581
+ ];
1582
+
1583
+ const stacks = detectStacks(items);
1584
+
1585
+ // Both PRs should be in the map as siblings of each other
1586
+ assert.ok(stacks.has(items[0].id), 'PR #101 should be in stacks map');
1587
+ assert.ok(stacks.has(items[1].id), 'PR #102 should be in stacks map');
1588
+ assert.deepStrictEqual(stacks.get(items[0].id), [items[1].id]);
1589
+ assert.deepStrictEqual(stacks.get(items[1].id), [items[0].id]);
1590
+ });
1591
+
1592
+ test('detects a 3-PR chain', async () => {
1593
+ const { detectStacks } = await import('../../service/poller.js');
1594
+
1595
+ const items = [
1596
+ {
1597
+ id: 'https://github.com/myorg/app/pull/101',
1598
+ number: 101,
1599
+ repository_full_name: 'myorg/app',
1600
+ _baseRefName: 'main',
1601
+ _headRefName: 'feature-part-1',
1602
+ },
1603
+ {
1604
+ id: 'https://github.com/myorg/app/pull/102',
1605
+ number: 102,
1606
+ repository_full_name: 'myorg/app',
1607
+ _baseRefName: 'feature-part-1',
1608
+ _headRefName: 'feature-part-2',
1609
+ },
1610
+ {
1611
+ id: 'https://github.com/myorg/app/pull/103',
1612
+ number: 103,
1613
+ repository_full_name: 'myorg/app',
1614
+ _baseRefName: 'feature-part-2',
1615
+ _headRefName: 'feature-part-3',
1616
+ },
1617
+ ];
1618
+
1619
+ const stacks = detectStacks(items);
1620
+
1621
+ // All three should be siblings of each other
1622
+ assert.ok(stacks.has(items[0].id));
1623
+ assert.ok(stacks.has(items[1].id));
1624
+ assert.ok(stacks.has(items[2].id));
1625
+
1626
+ // PR #101 should have #102 and #103 as siblings
1627
+ const siblings101 = stacks.get(items[0].id);
1628
+ assert.ok(siblings101.includes(items[1].id));
1629
+ assert.ok(siblings101.includes(items[2].id));
1630
+ assert.strictEqual(siblings101.length, 2);
1631
+
1632
+ // PR #102 should have #101 and #103 as siblings
1633
+ const siblings102 = stacks.get(items[1].id);
1634
+ assert.ok(siblings102.includes(items[0].id));
1635
+ assert.ok(siblings102.includes(items[2].id));
1636
+ assert.strictEqual(siblings102.length, 2);
1637
+ });
1638
+
1639
+ test('returns empty map when no stacks exist', async () => {
1640
+ const { detectStacks } = await import('../../service/poller.js');
1641
+
1642
+ const items = [
1643
+ {
1644
+ id: 'https://github.com/myorg/app/pull/101',
1645
+ number: 101,
1646
+ repository_full_name: 'myorg/app',
1647
+ _baseRefName: 'main',
1648
+ _headRefName: 'feature-a',
1649
+ },
1650
+ {
1651
+ id: 'https://github.com/myorg/app/pull/102',
1652
+ number: 102,
1653
+ repository_full_name: 'myorg/app',
1654
+ _baseRefName: 'main',
1655
+ _headRefName: 'feature-b',
1656
+ },
1657
+ ];
1658
+
1659
+ const stacks = detectStacks(items);
1660
+
1661
+ assert.strictEqual(stacks.size, 0, 'No stacks should be detected when all PRs are based on main');
1662
+ });
1663
+
1664
+ test('handles PRs from different repos independently', async () => {
1665
+ const { detectStacks } = await import('../../service/poller.js');
1666
+
1667
+ const items = [
1668
+ {
1669
+ id: 'https://github.com/myorg/app-a/pull/1',
1670
+ number: 1,
1671
+ repository_full_name: 'myorg/app-a',
1672
+ _baseRefName: 'main',
1673
+ _headRefName: 'feature-x',
1674
+ },
1675
+ {
1676
+ id: 'https://github.com/myorg/app-b/pull/2',
1677
+ number: 2,
1678
+ repository_full_name: 'myorg/app-b',
1679
+ _baseRefName: 'feature-x',
1680
+ _headRefName: 'feature-y',
1681
+ },
1682
+ ];
1683
+
1684
+ const stacks = detectStacks(items);
1685
+
1686
+ // Even though app-b PR #2's base matches app-a PR #1's head,
1687
+ // they're in different repos so should NOT be stacked
1688
+ assert.strictEqual(stacks.size, 0, 'Should not match branches across different repos');
1689
+ });
1690
+
1691
+ test('handles items missing branch refs gracefully', async () => {
1692
+ const { detectStacks } = await import('../../service/poller.js');
1693
+
1694
+ const items = [
1695
+ {
1696
+ id: 'https://github.com/myorg/app/pull/101',
1697
+ number: 101,
1698
+ repository_full_name: 'myorg/app',
1699
+ _baseRefName: 'main',
1700
+ _headRefName: 'feature-part-1',
1701
+ },
1702
+ {
1703
+ id: 'https://github.com/myorg/app/pull/102',
1704
+ number: 102,
1705
+ repository_full_name: 'myorg/app',
1706
+ // Missing _baseRefName and _headRefName (enrichment failed)
1707
+ },
1708
+ ];
1709
+
1710
+ const stacks = detectStacks(items);
1711
+
1712
+ assert.strictEqual(stacks.size, 0, 'Should not crash on items without branch refs');
1713
+ });
1714
+
1715
+ test('handles single item gracefully', async () => {
1716
+ const { detectStacks } = await import('../../service/poller.js');
1717
+
1718
+ const items = [
1719
+ {
1720
+ id: 'https://github.com/myorg/app/pull/101',
1721
+ number: 101,
1722
+ repository_full_name: 'myorg/app',
1723
+ _baseRefName: 'main',
1724
+ _headRefName: 'feature-1',
1725
+ },
1726
+ ];
1727
+
1728
+ const stacks = detectStacks(items);
1729
+
1730
+ assert.strictEqual(stacks.size, 0, 'Single item cannot form a stack');
1731
+ });
1732
+
1733
+ test('handles empty items array', async () => {
1734
+ const { detectStacks } = await import('../../service/poller.js');
1735
+
1736
+ const stacks = detectStacks([]);
1737
+
1738
+ assert.strictEqual(stacks.size, 0);
1739
+ });
1740
+
1741
+ test('uses repository.nameWithOwner as fallback for repo grouping', async () => {
1742
+ const { detectStacks } = await import('../../service/poller.js');
1743
+
1744
+ const items = [
1745
+ {
1746
+ id: 'https://github.com/myorg/app/pull/101',
1747
+ number: 101,
1748
+ repository: { nameWithOwner: 'myorg/app' },
1749
+ _baseRefName: 'main',
1750
+ _headRefName: 'feature-part-1',
1751
+ },
1752
+ {
1753
+ id: 'https://github.com/myorg/app/pull/102',
1754
+ number: 102,
1755
+ repository: { nameWithOwner: 'myorg/app' },
1756
+ _baseRefName: 'feature-part-1',
1757
+ _headRefName: 'feature-part-2',
1758
+ },
1759
+ ];
1760
+
1761
+ const stacks = detectStacks(items);
1762
+
1763
+ assert.ok(stacks.has(items[0].id));
1764
+ assert.ok(stacks.has(items[1].id));
1765
+ });
1766
+ });
1767
+
1768
+ describe('enrichItemsWithBranchRefs', () => {
1769
+ test('skips enrichment when detect_stacks is not set', async () => {
1770
+ const { enrichItemsWithBranchRefs } = await import('../../service/poller.js');
1771
+
1772
+ const items = [{ number: 1, repository_full_name: 'org/repo' }];
1773
+ const source = { tool: { command: ['gh', 'search', 'prs'] } }; // no detect_stacks
1774
+
1775
+ const result = await enrichItemsWithBranchRefs(items, source);
1776
+
1777
+ assert.strictEqual(result.length, 1);
1778
+ assert.strictEqual(result[0]._headRefName, undefined);
1779
+ assert.strictEqual(result[0]._baseRefName, undefined);
1780
+ });
1781
+
1782
+ test('skips enrichment for non-GitHub sources', async () => {
1783
+ const { enrichItemsWithBranchRefs } = await import('../../service/poller.js');
1784
+
1785
+ const items = [{ number: 1, repository_full_name: 'org/repo' }];
1786
+ const source = {
1787
+ detect_stacks: true,
1788
+ tool: { mcp: 'linear', name: 'list_issues' }
1789
+ };
1790
+
1791
+ const result = await enrichItemsWithBranchRefs(items, source);
1792
+
1793
+ assert.strictEqual(result.length, 1);
1794
+ assert.strictEqual(result[0]._headRefName, undefined);
1795
+ assert.strictEqual(result[0]._baseRefName, undefined);
1796
+ });
1797
+
1798
+ test('skips items without repository info', async () => {
1799
+ const { enrichItemsWithBranchRefs } = await import('../../service/poller.js');
1800
+
1801
+ const items = [
1802
+ { number: 1 }, // no repository_full_name
1803
+ { repository_full_name: 'org/repo' } // no number
1804
+ ];
1805
+ const source = {
1806
+ detect_stacks: true,
1807
+ tool: { command: ['gh', 'search', 'prs'] }
1808
+ };
1809
+
1810
+ const result = await enrichItemsWithBranchRefs(items, source);
1811
+
1812
+ // Items returned unchanged (no API calls for invalid items)
1813
+ assert.strictEqual(result.length, 2);
1814
+ assert.strictEqual(result[0]._headRefName, undefined);
1815
+ assert.strictEqual(result[1]._headRefName, undefined);
1816
+ });
1817
+ });
1441
1818
  });