@agentuity/opencode 0.1.39 → 0.1.41

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.
Files changed (161) hide show
  1. package/README.md +321 -9
  2. package/dist/agents/architect.d.ts +4 -0
  3. package/dist/agents/architect.d.ts.map +1 -0
  4. package/dist/agents/architect.js +259 -0
  5. package/dist/agents/architect.js.map +1 -0
  6. package/dist/agents/builder.d.ts +1 -1
  7. package/dist/agents/builder.d.ts.map +1 -1
  8. package/dist/agents/builder.js +44 -1
  9. package/dist/agents/builder.js.map +1 -1
  10. package/dist/agents/index.d.ts.map +1 -1
  11. package/dist/agents/index.js +6 -0
  12. package/dist/agents/index.js.map +1 -1
  13. package/dist/agents/lead.d.ts +1 -1
  14. package/dist/agents/lead.d.ts.map +1 -1
  15. package/dist/agents/lead.js +183 -19
  16. package/dist/agents/lead.js.map +1 -1
  17. package/dist/agents/planner.d.ts +4 -0
  18. package/dist/agents/planner.d.ts.map +1 -0
  19. package/dist/agents/planner.js +158 -0
  20. package/dist/agents/planner.js.map +1 -0
  21. package/dist/agents/runner.d.ts +4 -0
  22. package/dist/agents/runner.d.ts.map +1 -0
  23. package/dist/agents/runner.js +364 -0
  24. package/dist/agents/runner.js.map +1 -0
  25. package/dist/agents/types.d.ts +5 -1
  26. package/dist/agents/types.d.ts.map +1 -1
  27. package/dist/background/concurrency.d.ts +36 -0
  28. package/dist/background/concurrency.d.ts.map +1 -0
  29. package/dist/background/concurrency.js +92 -0
  30. package/dist/background/concurrency.js.map +1 -0
  31. package/dist/background/index.d.ts +5 -0
  32. package/dist/background/index.d.ts.map +1 -0
  33. package/dist/background/index.js +4 -0
  34. package/dist/background/index.js.map +1 -0
  35. package/dist/background/manager.d.ts +54 -0
  36. package/dist/background/manager.d.ts.map +1 -0
  37. package/dist/background/manager.js +409 -0
  38. package/dist/background/manager.js.map +1 -0
  39. package/dist/background/types.d.ts +47 -0
  40. package/dist/background/types.d.ts.map +1 -0
  41. package/dist/background/types.js +2 -0
  42. package/dist/background/types.js.map +1 -0
  43. package/dist/config/index.d.ts +2 -0
  44. package/dist/config/index.d.ts.map +1 -1
  45. package/dist/config/index.js +2 -0
  46. package/dist/config/index.js.map +1 -1
  47. package/dist/config/loader.d.ts +24 -0
  48. package/dist/config/loader.d.ts.map +1 -1
  49. package/dist/config/loader.js +102 -23
  50. package/dist/config/loader.js.map +1 -1
  51. package/dist/config/presets.d.ts +16 -0
  52. package/dist/config/presets.d.ts.map +1 -0
  53. package/dist/config/presets.js +20 -0
  54. package/dist/config/presets.js.map +1 -0
  55. package/dist/config/validation.d.ts +26 -0
  56. package/dist/config/validation.d.ts.map +1 -0
  57. package/dist/config/validation.js +48 -0
  58. package/dist/config/validation.js.map +1 -0
  59. package/dist/index.d.ts +1 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/plugin/hooks/keyword.d.ts.map +1 -1
  63. package/dist/plugin/hooks/keyword.js +3 -0
  64. package/dist/plugin/hooks/keyword.js.map +1 -1
  65. package/dist/plugin/plugin.d.ts.map +1 -1
  66. package/dist/plugin/plugin.js +297 -36
  67. package/dist/plugin/plugin.js.map +1 -1
  68. package/dist/skills/frontmatter.d.ts +7 -0
  69. package/dist/skills/frontmatter.d.ts.map +1 -0
  70. package/dist/skills/frontmatter.js +17 -0
  71. package/dist/skills/frontmatter.js.map +1 -0
  72. package/dist/skills/index.d.ts +4 -0
  73. package/dist/skills/index.d.ts.map +1 -0
  74. package/dist/skills/index.js +4 -0
  75. package/dist/skills/index.js.map +1 -0
  76. package/dist/skills/loader.d.ts +20 -0
  77. package/dist/skills/loader.d.ts.map +1 -0
  78. package/dist/skills/loader.js +152 -0
  79. package/dist/skills/loader.js.map +1 -0
  80. package/dist/skills/types.d.ts +41 -0
  81. package/dist/skills/types.d.ts.map +1 -0
  82. package/dist/skills/types.js +2 -0
  83. package/dist/skills/types.js.map +1 -0
  84. package/dist/tmux/decision-engine.d.ts +24 -0
  85. package/dist/tmux/decision-engine.d.ts.map +1 -0
  86. package/dist/tmux/decision-engine.js +193 -0
  87. package/dist/tmux/decision-engine.js.map +1 -0
  88. package/dist/tmux/executor.d.ts +56 -0
  89. package/dist/tmux/executor.d.ts.map +1 -0
  90. package/dist/tmux/executor.js +231 -0
  91. package/dist/tmux/executor.js.map +1 -0
  92. package/dist/tmux/index.d.ts +7 -0
  93. package/dist/tmux/index.d.ts.map +1 -0
  94. package/dist/tmux/index.js +7 -0
  95. package/dist/tmux/index.js.map +1 -0
  96. package/dist/tmux/manager.d.ts +80 -0
  97. package/dist/tmux/manager.d.ts.map +1 -0
  98. package/dist/tmux/manager.js +276 -0
  99. package/dist/tmux/manager.js.map +1 -0
  100. package/dist/tmux/state-query.d.ts +7 -0
  101. package/dist/tmux/state-query.d.ts.map +1 -0
  102. package/dist/tmux/state-query.js +67 -0
  103. package/dist/tmux/state-query.js.map +1 -0
  104. package/dist/tmux/types.d.ts +96 -0
  105. package/dist/tmux/types.d.ts.map +1 -0
  106. package/dist/tmux/types.js +8 -0
  107. package/dist/tmux/types.js.map +1 -0
  108. package/dist/tmux/utils.d.ts +32 -0
  109. package/dist/tmux/utils.d.ts.map +1 -0
  110. package/dist/tmux/utils.js +80 -0
  111. package/dist/tmux/utils.js.map +1 -0
  112. package/dist/tools/background.d.ts +61 -0
  113. package/dist/tools/background.d.ts.map +1 -0
  114. package/dist/tools/background.js +78 -0
  115. package/dist/tools/background.js.map +1 -0
  116. package/dist/tools/delegate.d.ts +6 -0
  117. package/dist/tools/delegate.d.ts.map +1 -1
  118. package/dist/tools/delegate.js +8 -2
  119. package/dist/tools/delegate.js.map +1 -1
  120. package/dist/tools/index.d.ts +1 -0
  121. package/dist/tools/index.d.ts.map +1 -1
  122. package/dist/tools/index.js +1 -0
  123. package/dist/tools/index.js.map +1 -1
  124. package/dist/types.d.ts +118 -18
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/types.js +49 -7
  127. package/dist/types.js.map +1 -1
  128. package/package.json +4 -3
  129. package/src/agents/architect.ts +262 -0
  130. package/src/agents/builder.ts +44 -1
  131. package/src/agents/index.ts +6 -0
  132. package/src/agents/lead.ts +183 -19
  133. package/src/agents/planner.ts +161 -0
  134. package/src/agents/runner.ts +367 -0
  135. package/src/agents/types.ts +5 -1
  136. package/src/background/concurrency.ts +116 -0
  137. package/src/background/index.ts +4 -0
  138. package/src/background/manager.ts +478 -0
  139. package/src/background/types.ts +52 -0
  140. package/src/config/index.ts +2 -0
  141. package/src/config/loader.ts +128 -31
  142. package/src/config/presets.ts +21 -0
  143. package/src/config/validation.ts +70 -0
  144. package/src/index.ts +1 -0
  145. package/src/plugin/hooks/keyword.ts +3 -0
  146. package/src/plugin/plugin.ts +323 -42
  147. package/src/skills/frontmatter.ts +25 -0
  148. package/src/skills/index.ts +3 -0
  149. package/src/skills/loader.ts +185 -0
  150. package/src/skills/types.ts +43 -0
  151. package/src/tmux/decision-engine.ts +246 -0
  152. package/src/tmux/executor.ts +286 -0
  153. package/src/tmux/index.ts +11 -0
  154. package/src/tmux/manager.ts +331 -0
  155. package/src/tmux/state-query.ts +74 -0
  156. package/src/tmux/types.ts +106 -0
  157. package/src/tmux/utils.ts +85 -0
  158. package/src/tools/background.ts +145 -0
  159. package/src/tools/delegate.ts +8 -2
  160. package/src/tools/index.ts +9 -0
  161. package/src/types.ts +88 -15
@@ -0,0 +1,185 @@
1
+ import { homedir } from 'node:os';
2
+ import { basename, join, resolve, sep } from 'node:path';
3
+ import { readdir, realpath } from 'node:fs/promises';
4
+ import type { LoadedSkill, SkillMetadata, SkillScope, SkillsConfig } from './types';
5
+ import { parseFrontmatter } from './frontmatter';
6
+
7
+ const SKILL_FILENAME = 'SKILL.md';
8
+
9
+ interface SkillDirConfig {
10
+ dir: string;
11
+ scope: SkillScope;
12
+ }
13
+
14
+ /**
15
+ * Load skills from standard directories.
16
+ * Priority order (later overrides earlier):
17
+ * 1. User OpenCode: ~/.config/opencode/skills/
18
+ * 2. User Agentuity: ~/.config/agentuity/opencode/skills/
19
+ * 3. User Claude Code compat: ~/.claude/skills/
20
+ * 4. Project OpenCode: ./.opencode/skills/
21
+ * 5. Project Claude Code compat: ./.claude/skills/
22
+ */
23
+ export async function loadAllSkills(config?: SkillsConfig): Promise<LoadedSkill[]> {
24
+ if (config?.enabled === false) {
25
+ return [];
26
+ }
27
+
28
+ const skillDirs: SkillDirConfig[] = [
29
+ { dir: join(homedir(), '.config', 'opencode', 'skills'), scope: 'user' },
30
+ { dir: join(homedir(), '.config', 'agentuity', 'opencode', 'skills'), scope: 'user' },
31
+ { dir: join(homedir(), '.claude', 'skills'), scope: 'user' },
32
+ { dir: join(process.cwd(), '.opencode', 'skills'), scope: 'project' },
33
+ { dir: join(process.cwd(), '.claude', 'skills'), scope: 'project' },
34
+ ];
35
+
36
+ if (config?.paths?.length) {
37
+ for (const skillPath of config.paths) {
38
+ const resolvedPath = resolve(skillPath);
39
+ skillDirs.push({ dir: resolvedPath, scope: inferScope(resolvedPath) });
40
+ }
41
+ }
42
+
43
+ const disabled = new Set(config?.disabled ?? []);
44
+ const skillsByName = new Map<string, LoadedSkill>();
45
+
46
+ for (const { dir, scope } of skillDirs) {
47
+ const skills = await loadSkillsFromDir(dir, scope);
48
+ for (const skill of skills) {
49
+ if (disabled.has(skill.name)) {
50
+ continue;
51
+ }
52
+ skillsByName.set(skill.name, skill);
53
+ }
54
+ }
55
+
56
+ return Array.from(skillsByName.values());
57
+ }
58
+
59
+ /**
60
+ * Load skills from a single directory
61
+ */
62
+ export async function loadSkillsFromDir(dir: string, scope: SkillScope): Promise<LoadedSkill[]> {
63
+ const results: LoadedSkill[] = [];
64
+ const resolvedDir = await safeRealpath(dir);
65
+
66
+ let entries: Array<{ name: string; isFile: () => boolean; isDirectory: () => boolean }>;
67
+ try {
68
+ entries = await readdir(resolvedDir, { withFileTypes: true });
69
+ } catch {
70
+ return results;
71
+ }
72
+
73
+ for (const entry of entries) {
74
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
75
+ const skillPath = join(dir, entry.name);
76
+ const skill = await loadSkillFromPath(
77
+ skillPath,
78
+ resolvedDir,
79
+ basename(entry.name, '.md'),
80
+ scope
81
+ );
82
+ if (skill) {
83
+ results.push(skill);
84
+ }
85
+ continue;
86
+ }
87
+
88
+ if (entry.isDirectory()) {
89
+ const subdirPath = join(dir, entry.name);
90
+ const resolvedSubdir = await safeRealpath(subdirPath);
91
+ const candidates = [
92
+ join(subdirPath, SKILL_FILENAME),
93
+ join(subdirPath, `${entry.name}.md`),
94
+ ];
95
+
96
+ for (const candidate of candidates) {
97
+ const skill = await loadSkillFromPath(candidate, resolvedSubdir, entry.name, scope);
98
+ if (skill) {
99
+ results.push(skill);
100
+ break;
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ return results;
107
+ }
108
+
109
+ /**
110
+ * Load a single skill file (supports both .md files and SKILL.md in directories)
111
+ */
112
+ async function loadSkillFromPath(
113
+ skillPath: string,
114
+ resolvedPath: string,
115
+ defaultName: string,
116
+ scope: SkillScope
117
+ ): Promise<LoadedSkill | null> {
118
+ const file = Bun.file(skillPath);
119
+ if (!(await file.exists())) {
120
+ return null;
121
+ }
122
+
123
+ const content = await file.text();
124
+ const parsed = parseFrontmatter<SkillMetadata>(content);
125
+ const metadata = parsed.data ?? {};
126
+ const name = metadata.name?.trim() || defaultName;
127
+ if (!name) {
128
+ return null;
129
+ }
130
+
131
+ const allowedTools = normalizeAllowedTools(metadata['allowed-tools']);
132
+
133
+ return {
134
+ name,
135
+ path: skillPath,
136
+ resolvedPath,
137
+ content: parsed.body,
138
+ metadata,
139
+ scope,
140
+ ...(allowedTools ? { allowedTools } : {}),
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Get skill by name from loaded skills
146
+ */
147
+ export function getSkillByName(skills: LoadedSkill[], name: string): LoadedSkill | undefined {
148
+ return skills.find((skill) => skill.name === name);
149
+ }
150
+
151
+ function normalizeAllowedTools(value?: string | string[]): string[] | undefined {
152
+ if (!value) return undefined;
153
+ if (Array.isArray(value)) {
154
+ const tools = value.filter((tool) => typeof tool === 'string');
155
+ return tools.length > 0 ? tools : undefined;
156
+ }
157
+ if (typeof value === 'string') {
158
+ const tool = value.trim();
159
+ return tool ? [tool] : undefined;
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ function inferScope(skillPath: string): SkillScope {
165
+ const resolved = resolve(skillPath);
166
+ const cwd = resolve(process.cwd());
167
+ const home = resolve(homedir());
168
+
169
+ if (isWithin(cwd, resolved)) return 'project';
170
+ if (isWithin(home, resolved)) return 'user';
171
+ return 'opencode';
172
+ }
173
+
174
+ function isWithin(base: string, target: string): boolean {
175
+ const baseWithSep = base.endsWith(sep) ? base : `${base}${sep}`;
176
+ return target === base || target.startsWith(baseWithSep);
177
+ }
178
+
179
+ async function safeRealpath(path: string): Promise<string> {
180
+ try {
181
+ return await realpath(path);
182
+ } catch {
183
+ return path;
184
+ }
185
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Skill scope determines where the skill was loaded from
3
+ * Priority order: project > user > opencode
4
+ */
5
+ export type SkillScope = 'project' | 'user' | 'opencode';
6
+
7
+ /**
8
+ * Frontmatter metadata parsed from skill files
9
+ */
10
+ export interface SkillMetadata {
11
+ name?: string;
12
+ description?: string;
13
+ model?: string;
14
+ agent?: string;
15
+ subtask?: boolean;
16
+ 'argument-hint'?: string;
17
+ 'allowed-tools'?: string | string[];
18
+ license?: string;
19
+ compatibility?: string;
20
+ metadata?: Record<string, string>;
21
+ }
22
+
23
+ /**
24
+ * A loaded skill ready for use
25
+ */
26
+ export interface LoadedSkill {
27
+ name: string;
28
+ path: string;
29
+ resolvedPath: string;
30
+ content: string;
31
+ metadata: SkillMetadata;
32
+ scope: SkillScope;
33
+ allowedTools?: string[];
34
+ }
35
+
36
+ /**
37
+ * Configuration for skills loading
38
+ */
39
+ export interface SkillsConfig {
40
+ enabled: boolean;
41
+ paths?: string[];
42
+ disabled?: string[];
43
+ }
@@ -0,0 +1,246 @@
1
+ import type {
2
+ CapacityConfig,
3
+ PaneAction,
4
+ SessionMapping,
5
+ SpawnDecision,
6
+ WindowState,
7
+ } from './types';
8
+ import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from './types';
9
+
10
+ /**
11
+ * Extended capacity config that includes maxPanes limit
12
+ */
13
+ export interface ExtendedCapacityConfig extends CapacityConfig {
14
+ maxPanes?: number;
15
+ }
16
+
17
+ /**
18
+ * Decide what actions to take to spawn a new agent pane
19
+ *
20
+ * Respects maxPanes limit with LRU rotation:
21
+ * - If current agent panes >= maxPanes, close oldest pane before spawning new one
22
+ * - This implements LRU (Least Recently Used) rotation
23
+ */
24
+ export function decideSpawnActions(
25
+ state: WindowState,
26
+ sessionId: string,
27
+ description: string,
28
+ config: ExtendedCapacityConfig,
29
+ sessionMappings: SessionMapping[]
30
+ ): SpawnDecision {
31
+ if (!state.mainPane) {
32
+ return { canSpawn: false, actions: [], reason: 'Main pane not found.' };
33
+ }
34
+
35
+ if (state.windowWidth < config.mainPaneMinWidth + config.agentPaneMinWidth) {
36
+ return { canSpawn: false, actions: [], reason: 'Window too small for split.' };
37
+ }
38
+
39
+ const capacity = calculateCapacity(state.windowWidth, state.windowHeight);
40
+ if (capacity.total <= 0) {
41
+ return { canSpawn: false, actions: [], reason: 'Window too small for agent panes.' };
42
+ }
43
+
44
+ // Determine effective max panes (user config or calculated capacity)
45
+ const effectiveMaxPanes = config.maxPanes ?? capacity.total;
46
+ const currentPaneCount = state.agentPanes.length;
47
+
48
+ // Check if we need LRU rotation (at or over maxPanes limit)
49
+ if (currentPaneCount >= effectiveMaxPanes) {
50
+ // Find oldest pane to close (LRU rotation)
51
+ const oldestMapping = pickOldestPane(sessionMappings);
52
+ if (!oldestMapping) {
53
+ // No tracked sessions, try replacement instead
54
+ const replacement = pickReplacementPane(state, sessionMappings);
55
+ if (!replacement) {
56
+ return { canSpawn: false, actions: [], reason: 'No pane available to replace.' };
57
+ }
58
+ return {
59
+ canSpawn: true,
60
+ actions: [
61
+ {
62
+ type: 'replace',
63
+ paneId: replacement.paneId,
64
+ oldSessionId: replacement.sessionId,
65
+ newSessionId: sessionId,
66
+ description,
67
+ },
68
+ ],
69
+ };
70
+ }
71
+
72
+ // Close oldest pane, then spawn new one
73
+ const actions: PaneAction[] = [
74
+ {
75
+ type: 'close',
76
+ paneId: oldestMapping.paneId,
77
+ sessionId: oldestMapping.sessionId,
78
+ },
79
+ ];
80
+
81
+ // After closing, we'll have room to spawn
82
+ // Determine where to spawn the new pane
83
+ if (currentPaneCount === 1) {
84
+ // After closing the only agent pane, split from main
85
+ actions.push({
86
+ type: 'spawn',
87
+ sessionId,
88
+ description,
89
+ targetPaneId: state.mainPane.paneId,
90
+ splitDirection: '-h',
91
+ });
92
+ } else {
93
+ // Find a remaining pane to split (not the one being closed)
94
+ const remainingPanes = state.agentPanes.filter((p) => p.paneId !== oldestMapping.paneId);
95
+ const targetPane = pickBestSplitPane(remainingPanes);
96
+ if (targetPane) {
97
+ const splitDirection = chooseSplitDirection(targetPane, config.agentPaneMinWidth);
98
+ actions.push({
99
+ type: 'spawn',
100
+ sessionId,
101
+ description,
102
+ targetPaneId: targetPane.paneId,
103
+ splitDirection,
104
+ });
105
+ } else {
106
+ // Fallback: split from main pane
107
+ actions.push({
108
+ type: 'spawn',
109
+ sessionId,
110
+ description,
111
+ targetPaneId: state.mainPane.paneId,
112
+ splitDirection: '-h',
113
+ });
114
+ }
115
+ }
116
+
117
+ return { canSpawn: true, actions };
118
+ }
119
+
120
+ // Under maxPanes limit - normal spawn logic
121
+ if (state.agentPanes.length === 0) {
122
+ if (!canSplitHorizontally(state.mainPane, config.agentPaneMinWidth)) {
123
+ return { canSpawn: false, actions: [], reason: 'Main pane too narrow to split.' };
124
+ }
125
+ return {
126
+ canSpawn: true,
127
+ actions: [
128
+ {
129
+ type: 'spawn',
130
+ sessionId,
131
+ description,
132
+ targetPaneId: state.mainPane.paneId,
133
+ splitDirection: '-h',
134
+ },
135
+ ],
136
+ };
137
+ }
138
+
139
+ const targetPane = pickBestSplitPane(state.agentPanes);
140
+ if (!targetPane) {
141
+ return { canSpawn: false, actions: [], reason: 'No suitable pane to split.' };
142
+ }
143
+ const splitDirection = chooseSplitDirection(targetPane, config.agentPaneMinWidth);
144
+ if (!canSplitPane(targetPane, splitDirection, config.agentPaneMinWidth)) {
145
+ return { canSpawn: false, actions: [], reason: 'No suitable pane to split.' };
146
+ }
147
+
148
+ return {
149
+ canSpawn: true,
150
+ actions: [
151
+ {
152
+ type: 'spawn',
153
+ sessionId,
154
+ description,
155
+ targetPaneId: targetPane.paneId,
156
+ splitDirection,
157
+ },
158
+ ],
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Calculate grid capacity based on window size
164
+ */
165
+ export function calculateCapacity(
166
+ windowWidth: number,
167
+ windowHeight: number
168
+ ): { cols: number; rows: number; total: number } {
169
+ const reservedWidth = Math.floor(windowWidth * 0.5);
170
+ const agentWidth = Math.max(0, windowWidth - reservedWidth);
171
+ const cols = Math.floor(agentWidth / MIN_PANE_WIDTH);
172
+ const rows = Math.floor(windowHeight / MIN_PANE_HEIGHT);
173
+ return {
174
+ cols: Math.max(0, cols),
175
+ rows: Math.max(0, rows),
176
+ total: Math.max(0, cols * rows),
177
+ };
178
+ }
179
+
180
+ function pickBestSplitPane(
181
+ panes: WindowState['agentPanes']
182
+ ): WindowState['agentPanes'][number] | undefined {
183
+ if (panes.length === 0) {
184
+ return undefined;
185
+ }
186
+ return panes.reduce((best, pane) => {
187
+ const bestArea = best.width * best.height;
188
+ const area = pane.width * pane.height;
189
+ return area > bestArea ? pane : best;
190
+ });
191
+ }
192
+
193
+ function chooseSplitDirection(
194
+ pane: WindowState['agentPanes'][number],
195
+ minAgentWidth: number
196
+ ): '-h' | '-v' {
197
+ const canSplitHorizontally = pane.width >= minAgentWidth * 2;
198
+ const canSplitVertically = pane.height >= MIN_PANE_HEIGHT * 2;
199
+ if (canSplitHorizontally && canSplitVertically) {
200
+ return pane.width >= pane.height ? '-h' : '-v';
201
+ }
202
+ if (canSplitHorizontally) return '-h';
203
+ if (canSplitVertically) return '-v';
204
+ return '-h';
205
+ }
206
+
207
+ function canSplitPane(
208
+ pane: WindowState['agentPanes'][number],
209
+ direction: '-h' | '-v',
210
+ minAgentWidth: number
211
+ ): boolean {
212
+ if (direction === '-h') {
213
+ return canSplitHorizontally(pane, minAgentWidth);
214
+ }
215
+ return pane.height >= MIN_PANE_HEIGHT * 2;
216
+ }
217
+
218
+ function canSplitHorizontally(
219
+ pane: WindowState['agentPanes'][number],
220
+ minAgentWidth: number
221
+ ): boolean {
222
+ return pane.width >= minAgentWidth * 2;
223
+ }
224
+
225
+ function pickReplacementPane(
226
+ state: WindowState,
227
+ sessionMappings: SessionMapping[]
228
+ ): SessionMapping | null {
229
+ const panesById = new Set(state.agentPanes.map((pane) => pane.paneId));
230
+ const candidates = sessionMappings.filter((mapping) => panesById.has(mapping.paneId));
231
+ if (candidates.length === 0) return null;
232
+ return candidates.reduce((oldest, entry) =>
233
+ entry.createdAt.getTime() < oldest.createdAt.getTime() ? entry : oldest
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Pick the oldest pane from session mappings (LRU - Least Recently Used)
239
+ * Used for rotation when maxPanes limit is reached
240
+ */
241
+ function pickOldestPane(sessionMappings: SessionMapping[]): SessionMapping | null {
242
+ if (sessionMappings.length === 0) return null;
243
+ return sessionMappings.reduce((oldest, entry) =>
244
+ entry.createdAt.getTime() < oldest.createdAt.getTime() ? entry : oldest
245
+ );
246
+ }