@abyrd9/harbor-cli 2.3.4 → 2.4.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.
package/README.md CHANGED
@@ -12,6 +12,7 @@ A CLI tool for managing local development services with ease. Harbor helps you o
12
12
  - 🖥️ **Tmux Integration**: Professional terminal multiplexing for service management
13
13
  - 📝 **Service Logging**: Stream service output to log files for monitoring and debugging
14
14
  - 🏷️ **Custom Session Names**: Configure unique tmux session names
15
+ - 🤖 **AI Agent Integration**: Inter-pane communication lets AI agents observe and interact with services
15
16
 
16
17
  ## Installation
17
18
 
@@ -158,10 +159,23 @@ Store configuration directly in your `package.json`:
158
159
  |---------|-------------|
159
160
  | `harbor dock` | Initialize Harbor config by auto-discovering services in your project |
160
161
  | `harbor moor` | Scan for and add new services to your existing Harbor configuration |
161
- | `harbor launch` | Start all services in a tmux session with pre-stage commands |
162
+ | `harbor launch` | Start all services in a tmux session (`-d` for headless) |
163
+ | `harbor anchor` | Attach to a running Harbor session |
164
+ | `harbor scuttle` | Stop all services and kill the session |
165
+ | `harbor bearings` | Show status of running services |
162
166
  | `harbor --help` | Show comprehensive help with feature descriptions |
163
167
  | `harbor --version` | Show version information |
164
168
 
169
+ ### Inter-Pane Communication
170
+
171
+ | Command | Description |
172
+ |---------|-------------|
173
+ | `harbor hail <service> "<cmd>"` | Send keystrokes to another service pane |
174
+ | `harbor survey <service>` | Capture output from a service pane |
175
+ | `harbor parley <service> "<cmd>"` | Execute command and capture response |
176
+ | `harbor whoami` | Show current pane identity and access |
177
+ | `harbor context` | Output full session context for AI agents |
178
+
165
179
  ### Command Options
166
180
 
167
181
  - `-p, --path <path>`: Specify project root path (defaults to `./`)
@@ -254,13 +268,95 @@ Enable logging to stream service output to files in `.harbor/`. This is particul
254
268
  - `maxLogLines`: Maximum lines to keep in log file (default: `1000`)
255
269
 
256
270
  **Log files are:**
257
- - Stored in `.harbor/` directory (automatically added to `.gitignore`)
271
+ - Stored in `.harbor/` directory (add to `.gitignore` manually)
258
272
  - Named `{session}-{service}.log` (e.g., `local-dev-test-api.log`)
259
273
  - Automatically trimmed to prevent unbounded growth
260
274
  - Stripped of ANSI escape codes for clean, readable output
261
275
 
262
276
  **Use case:** Point your AI assistant to the `.harbor/` folder so it can monitor service logs, spot errors, and understand runtime behavior while helping you develop.
263
277
 
278
+ ### Inter-Pane Communication for AI Agents
279
+
280
+ Harbor enables AI agents running in one pane to observe and interact with other services. This is powerful for AI-assisted development workflows where an agent needs to monitor logs, send commands to REPLs, or coordinate between services.
281
+
282
+ #### Configuration
283
+
284
+ Add `canAccess` to specify which services a pane can communicate with:
285
+
286
+ ```json
287
+ {
288
+ "services": [
289
+ {
290
+ "name": "repl",
291
+ "path": "./backend",
292
+ "command": "bin/mycli"
293
+ },
294
+ {
295
+ "name": "agent",
296
+ "path": ".",
297
+ "command": "opencode",
298
+ "canAccess": ["repl", "web"]
299
+ },
300
+ {
301
+ "name": "web",
302
+ "path": "./frontend",
303
+ "command": "npm run dev"
304
+ }
305
+ ]
306
+ }
307
+ ```
308
+
309
+ #### Commands
310
+
311
+ **Survey** - Capture output from another pane:
312
+ ```bash
313
+ harbor survey repl --lines 50
314
+ ```
315
+
316
+ **Hail** - Send keystrokes to another pane (fire-and-forget):
317
+ ```bash
318
+ harbor hail repl "user query --email test@example.com"
319
+ ```
320
+
321
+ **Parley** - Execute command and capture response (uses markers for clean output):
322
+ ```bash
323
+ harbor parley repl "users" --timeout 5000
324
+ ```
325
+
326
+ **Whoami** - Check current pane identity and access:
327
+ ```bash
328
+ harbor whoami
329
+ ```
330
+
331
+ **Context** - Get full session documentation (markdown, great for AI context):
332
+ ```bash
333
+ harbor context
334
+ ```
335
+
336
+ #### Access Control
337
+
338
+ - Services can only access panes listed in their `canAccess` array
339
+ - Commands run from outside tmux (external terminal) bypass access control
340
+ - Access is enforced based on the `HARBOR_SERVICE` environment variable
341
+
342
+ #### Environment Variables
343
+
344
+ Each pane automatically receives these environment variables:
345
+ - `HARBOR_SESSION` - Session name
346
+ - `HARBOR_SOCKET` - Tmux socket name
347
+ - `HARBOR_SERVICE` - Current service name
348
+ - `HARBOR_WINDOW` - Window number
349
+
350
+ #### Use Case: AI Agent Integration
351
+
352
+ Add to your agent's instructions:
353
+
354
+ ```markdown
355
+ When starting, run `harbor whoami` to check your harbor context.
356
+ Use `harbor survey <service>` to observe other panes.
357
+ Use `harbor parley <service> "<cmd>"` to interact with REPLs/CLIs.
358
+ ```
359
+
264
360
  ### Before/After Scripts
265
361
  Run custom scripts before and after your services start:
266
362
 
package/dist/index.js CHANGED
@@ -2,13 +2,16 @@
2
2
  import { Command } from '@commander-js/extra-typings';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { spawn } from 'node:child_process';
5
+ import { spawn, exec } from 'node:child_process';
6
6
  import { chmodSync } from 'node:fs';
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { promisify } from 'node:util';
10
+ import { randomUUID } from 'node:crypto';
9
11
  import os from 'node:os';
10
12
  import readline from 'node:readline';
11
13
  import pc from 'picocolors';
14
+ const execAsync = promisify(exec);
12
15
  // Colored output helpers
13
16
  const log = {
14
17
  error: (msg) => console.log(`${pc.red('✗')} ${msg}`),
@@ -175,6 +178,123 @@ async function checkDependencies() {
175
178
  throw new Error('Please install missing dependencies before continuing');
176
179
  }
177
180
  }
181
+ // ─────────────────────────────────────────────────────────────
182
+ // Inter-Pane Communication Functions
183
+ // ─────────────────────────────────────────────────────────────
184
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
185
+ function getSessionInfo() {
186
+ const sessionFile = path.join(process.cwd(), '.harbor', 'session.json');
187
+ if (!fs.existsSync(sessionFile))
188
+ return null;
189
+ try {
190
+ return JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ }
196
+ function checkAccess(target) {
197
+ const session = getSessionInfo();
198
+ if (!session) {
199
+ return { allowed: false, error: 'No harbor session running. Run "harbor launch" first.' };
200
+ }
201
+ const targetService = session.services[target];
202
+ if (!targetService) {
203
+ const available = Object.keys(session.services).join(', ');
204
+ return { allowed: false, error: `Unknown service: ${target}. Available services: ${available}` };
205
+ }
206
+ // If called from outside a harbor pane (no HARBOR_SERVICE env), allow access
207
+ const callerService = process.env.HARBOR_SERVICE;
208
+ if (!callerService) {
209
+ return { allowed: true };
210
+ }
211
+ // If called from within a harbor pane, check canAccess
212
+ const callerInfo = session.services[callerService];
213
+ if (!callerInfo) {
214
+ return { allowed: true }; // Caller not in session, allow
215
+ }
216
+ const canAccess = callerInfo.canAccess || [];
217
+ if (!canAccess.includes(target)) {
218
+ return {
219
+ allowed: false,
220
+ error: `Service "${callerService}" does not have access to "${target}". Add "${target}" to canAccess in your harbor config.`
221
+ };
222
+ }
223
+ return { allowed: true };
224
+ }
225
+ async function sendToPane(target, command) {
226
+ const session = getSessionInfo();
227
+ if (!session)
228
+ throw new Error('No harbor session running');
229
+ const service = session.services[target];
230
+ if (!service)
231
+ throw new Error(`Unknown service: ${target}`);
232
+ const tmuxCmd = `tmux -L ${session.socket}`;
233
+ const escaped = command.replace(/"/g, '\\"');
234
+ await execAsync(`${tmuxCmd} send-keys -t "${service.target}" "${escaped}" Enter`);
235
+ }
236
+ async function capturePane(target, lines = 500) {
237
+ const session = getSessionInfo();
238
+ if (!session)
239
+ throw new Error('No harbor session running');
240
+ const service = session.services[target];
241
+ if (!service)
242
+ throw new Error(`Unknown service: ${target}`);
243
+ const tmuxCmd = `tmux -L ${session.socket}`;
244
+ const { stdout } = await execAsync(`${tmuxCmd} capture-pane -t "${service.target}" -p -S -${lines}`);
245
+ return stdout;
246
+ }
247
+ async function execInPane(target, command, timeout = 3000) {
248
+ const session = getSessionInfo();
249
+ if (!session)
250
+ throw new Error('No harbor session running');
251
+ const service = session.services[target];
252
+ if (!service)
253
+ throw new Error(`Unknown service: ${target}`);
254
+ const tmuxCmd = `tmux -L ${session.socket}`;
255
+ const markerId = randomUUID().slice(0, 8);
256
+ const startMarker = `<<<HARBOR_START_${markerId}>>>`;
257
+ const endMarker = `<<<HARBOR_END_${markerId}>>>`;
258
+ // Send start marker
259
+ await execAsync(`${tmuxCmd} send-keys -t "${service.target}" "echo '${startMarker}'" Enter`);
260
+ await sleep(100);
261
+ // Send command
262
+ const escaped = command.replace(/'/g, "'\\''");
263
+ await execAsync(`${tmuxCmd} send-keys -t "${service.target}" '${escaped}' Enter`);
264
+ await sleep(timeout);
265
+ // Send end marker
266
+ await execAsync(`${tmuxCmd} send-keys -t "${service.target}" "echo '${endMarker}'" Enter`);
267
+ await sleep(200);
268
+ // Capture and extract
269
+ const { stdout } = await execAsync(`${tmuxCmd} capture-pane -t "${service.target}" -p -S -500`);
270
+ // Extract content between markers
271
+ const regex = new RegExp(`${escapeRegex(startMarker)}\\n([\\s\\S]*?)${escapeRegex(endMarker)}`);
272
+ const match = stdout.match(regex);
273
+ if (match) {
274
+ // Clean up the output
275
+ const rawOutput = match[1];
276
+ const lines = rawOutput.split('\n');
277
+ // Filter out the echoed command and prompts
278
+ const cleanedLines = lines.filter(line => {
279
+ const trimmed = line.trim();
280
+ if (!trimmed)
281
+ return false;
282
+ if (trimmed.includes(`echo '${startMarker}'`))
283
+ return false;
284
+ if (trimmed.includes(`echo '${endMarker}'`))
285
+ return false;
286
+ return true;
287
+ });
288
+ return cleanedLines.join('\n').trim() || '(no output)';
289
+ }
290
+ return stdout.trim() || '(no output)';
291
+ }
292
+ function escapeRegex(str) {
293
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
294
+ }
295
+ // ─────────────────────────────────────────────────────────────
296
+ // Configuration Prompts
297
+ // ─────────────────────────────────────────────────────────────
178
298
  function promptConfigLocation() {
179
299
  const rl = readline.createInterface({
180
300
  input: process.stdin,
@@ -237,6 +357,15 @@ ${yellow('Commands:')}
237
357
  ${green('scuttle')} Stop all services
238
358
  ${green('bearings')} Show status of running services
239
359
 
360
+ ${yellow('Inter-Pane Communication:')}
361
+ ${green('hail')} Send a command to another service pane
362
+ ${green('survey')} Capture output from a service pane
363
+ ${green('parley')} Execute command and capture response
364
+
365
+ ${yellow('Agent Awareness:')}
366
+ ${green('whoami')} Show current pane identity and access
367
+ ${green('context')} Output full session context (markdown)
368
+
240
369
  ${yellow('Quick Start:')}
241
370
  ${dim('$')} harbor dock ${dim('# Create config')}
242
371
  ${dim('$')} harbor launch ${dim('# Start services')}
@@ -435,6 +564,12 @@ program.command('scuttle')
435
564
  killSession.on('close', async (code) => {
436
565
  if (code === 0) {
437
566
  log.success(`Harbor session ${pc.cyan(sessionName)} stopped`);
567
+ // Clean up session.json
568
+ const sessionFile = path.join(process.cwd(), '.harbor', 'session.json');
569
+ if (fs.existsSync(sessionFile)) {
570
+ fs.unlinkSync(sessionFile);
571
+ log.dim(' Cleaned up session metadata');
572
+ }
438
573
  // Execute after scripts when session is killed
439
574
  if (config.after && config.after.length > 0) {
440
575
  try {
@@ -538,6 +673,196 @@ program.command('bearings')
538
673
  process.exit(1);
539
674
  }
540
675
  });
676
+ // ─────────────────────────────────────────────────────────────
677
+ // Inter-Pane Communication Commands
678
+ // ─────────────────────────────────────────────────────────────
679
+ program.command('hail')
680
+ .description('Send a command to another service pane')
681
+ .argument('<service>', 'Target service name')
682
+ .argument('<command>', 'Command to send')
683
+ .action(async (service, command) => {
684
+ try {
685
+ const access = checkAccess(service);
686
+ if (!access.allowed) {
687
+ log.error(access.error || 'Access denied');
688
+ process.exit(1);
689
+ }
690
+ await sendToPane(service, command);
691
+ log.success(`Hailed ${pc.cyan(service)}: ${pc.dim(command)}`);
692
+ }
693
+ catch (err) {
694
+ log.error(err instanceof Error ? err.message : 'Failed to hail service');
695
+ process.exit(1);
696
+ }
697
+ });
698
+ program.command('survey')
699
+ .description('Capture output from a service pane')
700
+ .argument('<service>', 'Target service name')
701
+ .option('-n, --lines <number>', 'Number of lines to capture', '500')
702
+ .action(async (service, options) => {
703
+ try {
704
+ const access = checkAccess(service);
705
+ if (!access.allowed) {
706
+ log.error(access.error || 'Access denied');
707
+ process.exit(1);
708
+ }
709
+ const output = await capturePane(service, parseInt(options.lines));
710
+ console.log(output);
711
+ }
712
+ catch (err) {
713
+ log.error(err instanceof Error ? err.message : 'Failed to survey service');
714
+ process.exit(1);
715
+ }
716
+ });
717
+ program.command('parley')
718
+ .description('Execute a command in a pane and capture the response')
719
+ .argument('<service>', 'Target service name')
720
+ .argument('<command>', 'Command to execute')
721
+ .option('-t, --timeout <ms>', 'Timeout in milliseconds', '3000')
722
+ .action(async (service, command, options) => {
723
+ try {
724
+ const access = checkAccess(service);
725
+ if (!access.allowed) {
726
+ log.error(access.error || 'Access denied');
727
+ process.exit(1);
728
+ }
729
+ const output = await execInPane(service, command, parseInt(options.timeout));
730
+ console.log(output);
731
+ }
732
+ catch (err) {
733
+ log.error(err instanceof Error ? err.message : 'Failed to parley with service');
734
+ process.exit(1);
735
+ }
736
+ });
737
+ program.command('whoami')
738
+ .description('Show current pane identity and session info')
739
+ .action(async () => {
740
+ const session = getSessionInfo();
741
+ const currentService = process.env.HARBOR_SERVICE;
742
+ if (!session) {
743
+ log.warn('Not in a harbor session');
744
+ log.dim(' No .harbor/session.json found');
745
+ process.exit(0);
746
+ }
747
+ const currentServiceInfo = currentService ? session.services[currentService] : null;
748
+ const canAccessList = currentServiceInfo?.canAccess || [];
749
+ log.header(`${pc.cyan('⚓')} Harbor Identity`);
750
+ log.plain('');
751
+ log.label('Session:', session.session);
752
+ log.label('Socket:', session.socket);
753
+ if (currentService && currentServiceInfo) {
754
+ log.label('You are:', `${pc.green(currentService)} (window ${currentServiceInfo.window})`);
755
+ if (canAccessList.length > 0) {
756
+ log.label('Can access:', canAccessList.map(s => pc.cyan(s)).join(', '));
757
+ }
758
+ else {
759
+ log.label('Can access:', pc.dim('(none configured)'));
760
+ }
761
+ }
762
+ else {
763
+ log.label('You are:', pc.dim('external (not in a harbor pane)'));
764
+ log.label('Can access:', pc.green('all services'));
765
+ }
766
+ log.plain('');
767
+ log.dim(' Run "harbor context" for full documentation');
768
+ log.dim(' Run "harbor bearings" to see all services');
769
+ });
770
+ program.command('context')
771
+ .description('Output session context for AI agents (markdown format)')
772
+ .action(async () => {
773
+ const session = getSessionInfo();
774
+ const currentService = process.env.HARBOR_SERVICE;
775
+ if (!session) {
776
+ console.log(`# Harbor Session
777
+
778
+ No active harbor session found. Run \`harbor launch\` to start one.
779
+ `);
780
+ process.exit(0);
781
+ }
782
+ const currentServiceInfo = currentService ? session.services[currentService] : null;
783
+ const canAccessList = currentServiceInfo?.canAccess || [];
784
+ let output = `# Harbor Session Context
785
+
786
+ You are running inside a **harbor** tmux session, which orchestrates multiple development services.
787
+
788
+ ## Current Session
789
+ - **Session**: ${session.session}
790
+ - **Socket**: ${session.socket}
791
+ - **Started**: ${session.startedAt}
792
+ `;
793
+ if (currentService) {
794
+ output += `- **Your Pane**: ${currentService} (window ${currentServiceInfo?.window})
795
+ `;
796
+ }
797
+ output += `
798
+ ## Available Services
799
+ | Service | Window | You Can Access |
800
+ |---------|--------|----------------|
801
+ `;
802
+ for (const [name, info] of Object.entries(session.services)) {
803
+ const isCurrent = name === currentService;
804
+ const hasAccess = !currentService || canAccessList.includes(name) || name === currentService;
805
+ const accessIcon = isCurrent ? '(you)' : hasAccess ? '✓' : '✗';
806
+ output += `| ${name} | ${info.window} | ${accessIcon} |\n`;
807
+ }
808
+ output += `
809
+ ## Inter-Pane Communication Commands
810
+
811
+ You can interact with other service panes using these commands:
812
+
813
+ ### \`harbor hail <service> "<command>"\`
814
+ Send keystrokes to another pane (fire-and-forget).
815
+ \`\`\`bash
816
+ harbor hail repl "echo hello"
817
+ \`\`\`
818
+
819
+ ### \`harbor survey <service> [--lines N]\`
820
+ Capture the current output/scrollback from another pane.
821
+ \`\`\`bash
822
+ harbor survey web --lines 50
823
+ \`\`\`
824
+
825
+ ### \`harbor parley <service> "<command>" [--timeout ms]\`
826
+ Execute a command in another pane and capture the response.
827
+ Uses markers to delimit output. Good for REPLs and CLIs.
828
+ \`\`\`bash
829
+ harbor parley repl "users" --timeout 3000
830
+ \`\`\`
831
+
832
+ ## Access Control
833
+ `;
834
+ if (currentService) {
835
+ if (canAccessList.length > 0) {
836
+ output += `Your service (${currentService}) can access: **${canAccessList.join(', ')}**
837
+
838
+ To access other services, add them to \`canAccess\` in harbor.json and restart the session.
839
+ `;
840
+ }
841
+ else {
842
+ output += `Your service (${currentService}) has no \`canAccess\` configured.
843
+
844
+ Add services to \`canAccess\` in harbor.json to enable inter-pane communication:
845
+ \`\`\`json
846
+ {
847
+ "name": "${currentService}",
848
+ "canAccess": ["repl", "web"]
849
+ }
850
+ \`\`\`
851
+ `;
852
+ }
853
+ }
854
+ else {
855
+ output += `You are running from outside the harbor session, so you have access to all services.
856
+ `;
857
+ }
858
+ output += `
859
+ ## Other Useful Commands
860
+ - \`harbor bearings\` - Show session status and running services
861
+ - \`harbor anchor\` - Attach to the tmux session interactively
862
+ - \`harbor scuttle\` - Stop all services
863
+ `;
864
+ console.log(output);
865
+ });
541
866
  program.parse();
542
867
  function fileExists(path) {
543
868
  return fs.existsSync(`${process.cwd()}/${path}`);
@@ -556,6 +881,7 @@ export function validateConfig(config) {
556
881
  if (!Array.isArray(config.services)) {
557
882
  return 'Services must be an array';
558
883
  }
884
+ const serviceNames = new Set(config.services.map(s => s.name));
559
885
  for (const service of config.services) {
560
886
  if (!service.name) {
561
887
  return 'Service name is required';
@@ -563,6 +889,17 @@ export function validateConfig(config) {
563
889
  if (!service.path) {
564
890
  return 'Service path is required';
565
891
  }
892
+ // Validate canAccess references
893
+ if (service.canAccess) {
894
+ for (const targetName of service.canAccess) {
895
+ if (!serviceNames.has(targetName)) {
896
+ return `Service "${service.name}" has canAccess reference to unknown service "${targetName}"`;
897
+ }
898
+ if (targetName === service.name) {
899
+ return `Service "${service.name}" cannot have canAccess reference to itself`;
900
+ }
901
+ }
902
+ }
566
903
  }
567
904
  // Validate before scripts
568
905
  if (config.before && !Array.isArray(config.before)) {
@@ -867,17 +1204,6 @@ function ensureLogSetup(config) {
867
1204
  if (!fs.existsSync(harborDir)) {
868
1205
  fs.mkdirSync(harborDir, { recursive: true });
869
1206
  }
870
- const gitignorePath = path.join(process.cwd(), '.gitignore');
871
- if (!fs.existsSync(gitignorePath)) {
872
- return;
873
- }
874
- const gitignore = fs.readFileSync(gitignorePath, 'utf-8');
875
- if (gitignore.includes('.harbor')) {
876
- return;
877
- }
878
- const needsNewline = gitignore.length > 0 && !gitignore.endsWith('\n');
879
- const entry = `${needsNewline ? '\n' : ''}.harbor/\n`;
880
- fs.appendFileSync(gitignorePath, entry, 'utf-8');
881
1207
  }
882
1208
  // Get the package root directory
883
1209
  function getPackageRoot() {
@@ -38,6 +38,11 @@
38
38
  "type": "integer",
39
39
  "description": "Maximum number of lines to keep in the log file",
40
40
  "minimum": 1
41
+ },
42
+ "canAccess": {
43
+ "type": "array",
44
+ "items": { "type": "string" },
45
+ "description": "Names of other services this pane can send commands to via hail/survey/parley"
41
46
  }
42
47
  },
43
48
  "additionalProperties": false
@@ -38,6 +38,11 @@
38
38
  "type": "integer",
39
39
  "description": "Maximum number of lines to keep in the log file",
40
40
  "minimum": 1
41
+ },
42
+ "canAccess": {
43
+ "type": "array",
44
+ "items": { "type": "string" },
45
+ "description": "Names of other services this pane can send commands to via hail/survey/parley"
41
46
  }
42
47
  },
43
48
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abyrd9/harbor-cli",
3
- "version": "2.3.4",
3
+ "version": "2.4.1",
4
4
  "description": "A CLI tool for orchestrating local development services in a tmux session. Perfect for microservices and polyglot projects with automatic service discovery and before/after script support.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/dev.sh CHANGED
@@ -149,23 +149,75 @@ while read service; do
149
149
  echo "Path: $path"
150
150
  echo "Command: $command"
151
151
 
152
+ # Build the environment export command for inter-pane communication
153
+ env_export="export HARBOR_SESSION='$session_name' HARBOR_SOCKET='$socket_name' HARBOR_SERVICE='$name' HARBOR_WINDOW=$window_index"
154
+
152
155
  if [ "$log" = "true" ]; then
153
156
  log_file="$repo_root/.harbor/${session_name}-${name}.log"
154
157
  : > "$log_file"
155
158
  # Use pipe-pane to capture ALL terminal output (works with any program, no buffering issues)
156
159
  $tmux_cmd new-window -t "$session_name":$window_index -n "$name"
157
160
  $tmux_cmd pipe-pane -t "$session_name":$window_index "cat >> \"$log_file\""
158
- $tmux_cmd send-keys -t "$session_name":$window_index "cd \"$path\" && $command" C-m
161
+ # Inject environment variables then run command
162
+ $tmux_cmd send-keys -t "$session_name":$window_index "$env_export && cd \"$path\" && $command" C-m
159
163
  # Start background process to trim logs if they get too large
160
164
  start_log_trim "$log_file" "$effective_max_lines"
161
165
  else
162
166
  $tmux_cmd new-window -t "$session_name":$window_index -n "$name"
163
- $tmux_cmd send-keys -t "$session_name":$window_index "cd \"$path\" && $command" C-m
167
+ # Inject environment variables then run command
168
+ $tmux_cmd send-keys -t "$session_name":$window_index "$env_export && cd \"$path\" && $command" C-m
164
169
  fi
165
170
 
166
171
  ((window_index++))
167
172
  done < <(get_harbor_config | jq -c '.services[]')
168
173
 
174
+ # Generate session.json for inter-pane communication
175
+ echo "Generating session metadata..."
176
+ mkdir -p "$repo_root/.harbor"
177
+
178
+ # Build the session JSON
179
+ session_json=$(cat <<EOF
180
+ {
181
+ "session": "$session_name",
182
+ "socket": "$socket_name",
183
+ "startedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
184
+ "services": {
185
+ EOF
186
+ )
187
+
188
+ # Add each service to the JSON
189
+ first_service=true
190
+ svc_window_index=1
191
+ while read service; do
192
+ name=$(echo $service | jq -r '.name')
193
+ can_access=$(echo $service | jq -c '.canAccess // []')
194
+
195
+ if [ "$first_service" = true ]; then
196
+ first_service=false
197
+ else
198
+ session_json+=","
199
+ fi
200
+
201
+ session_json+=$(cat <<EOF
202
+
203
+ "$name": {
204
+ "window": $svc_window_index,
205
+ "target": "$session_name:$svc_window_index",
206
+ "canAccess": $can_access
207
+ }
208
+ EOF
209
+ )
210
+
211
+ ((svc_window_index++))
212
+ done < <(get_harbor_config | jq -c '.services[]')
213
+
214
+ session_json+="
215
+ }
216
+ }"
217
+
218
+ echo "$session_json" > "$repo_root/.harbor/session.json"
219
+ echo "Session metadata written to .harbor/session.json"
220
+
169
221
  # Bind 'Home' key to switch to the terminal window
170
222
  $tmux_cmd bind-key -n Home select-window -t :0
171
223