aimux-cli 0.1.3 → 0.1.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.
Files changed (45) hide show
  1. package/README.md +20 -19
  2. package/dist/agent-tracker.js +15 -13
  3. package/dist/agent-tracker.js.map +1 -1
  4. package/dist/context/context-bridge.js +8 -1
  5. package/dist/context/context-bridge.js.map +1 -1
  6. package/dist/daemon.js +8 -1
  7. package/dist/daemon.js.map +1 -1
  8. package/dist/dashboard-pending-actions.js +1 -1
  9. package/dist/dashboard-pending-actions.js.map +1 -1
  10. package/dist/dashboard-session-actions.d.ts +4 -4
  11. package/dist/dashboard-session-actions.js.map +1 -1
  12. package/dist/dashboard.d.ts +2 -2
  13. package/dist/key-parser.js +7 -0
  14. package/dist/key-parser.js.map +1 -1
  15. package/dist/main.js +227 -41
  16. package/dist/main.js.map +1 -1
  17. package/dist/metadata-server.d.ts +18 -0
  18. package/dist/metadata-server.js +22 -0
  19. package/dist/metadata-server.js.map +1 -1
  20. package/dist/multiplexer.d.ts +14 -0
  21. package/dist/multiplexer.js +329 -41
  22. package/dist/multiplexer.js.map +1 -1
  23. package/dist/notification-context.d.ts +1 -0
  24. package/dist/notification-context.js +12 -0
  25. package/dist/notification-context.js.map +1 -1
  26. package/dist/plugin-runtime.js +8 -2
  27. package/dist/plugin-runtime.js.map +1 -1
  28. package/dist/project-scanner.js +14 -3
  29. package/dist/project-scanner.js.map +1 -1
  30. package/dist/shell-hooks.d.ts +27 -0
  31. package/dist/shell-hooks.js +137 -0
  32. package/dist/shell-hooks.js.map +1 -0
  33. package/dist/tmux-doctor.d.ts +10 -0
  34. package/dist/tmux-doctor.js +62 -0
  35. package/dist/tmux-doctor.js.map +1 -1
  36. package/dist/tmux-runtime-manager.d.ts +8 -0
  37. package/dist/tmux-runtime-manager.js +51 -12
  38. package/dist/tmux-runtime-manager.js.map +1 -1
  39. package/dist/tmux-statusline.js +1 -1
  40. package/dist/tmux-statusline.js.map +1 -1
  41. package/dist/tool-output-watchers.js +128 -33
  42. package/dist/tool-output-watchers.js.map +1 -1
  43. package/dist/tui/screens/dashboard-renderers.js +2 -1
  44. package/dist/tui/screens/dashboard-renderers.js.map +1 -1
  45. package/package.json +1 -1
@@ -41,6 +41,7 @@ import { appendSessionMessage, readSessionMessages } from "./session-message-his
41
41
  import { ProjectEventBus } from "./project-events.js";
42
42
  import { deriveSessionSemantics } from "./session-semantics.js";
43
43
  import { injectClaudeHookArgs } from "./claude-hooks.js";
44
+ import { wrapCommandWithShellIntegration, wrapInteractiveShellWithIntegration } from "./shell-hooks.js";
44
45
  import { navigationUrgencyScore } from "./fast-control.js";
45
46
  import { requestJson } from "./http-client.js";
46
47
  import { openDashboardTarget } from "./dashboard-targets.js";
@@ -60,6 +61,7 @@ import { graveyardSessionWithFeedback as runGraveyardSessionWithFeedback, resume
60
61
  export class Multiplexer {
61
62
  projectRoot;
62
63
  sessions = [];
64
+ offlineServices = [];
63
65
  activeIndex = 0;
64
66
  mode = "dashboard";
65
67
  hotkeys;
@@ -168,6 +170,7 @@ export class Multiplexer {
168
170
  dashboardServicesCache = [];
169
171
  dashboardWorktreeGroupsCache = [];
170
172
  dashboardMainCheckoutInfoCache = { name: "Main Checkout", branch: "" };
173
+ dashboardModelSnapshotKey = null;
171
174
  dashboardModelRefreshedAt = 0;
172
175
  dashboardServiceSnapshotRefreshing = false;
173
176
  dashboardServiceRecovery = null;
@@ -356,12 +359,24 @@ export class Multiplexer {
356
359
  });
357
360
  }
358
361
  applyDashboardModel(dashSessions, dashServices, worktreeGroups, mainCheckoutInfo) {
362
+ const snapshotKey = JSON.stringify({
363
+ sessions: dashSessions,
364
+ services: dashServices,
365
+ worktreeGroups,
366
+ mainCheckoutInfo,
367
+ });
368
+ if (snapshotKey === this.dashboardModelSnapshotKey) {
369
+ this.dashboardModelRefreshedAt = Date.now();
370
+ return false;
371
+ }
372
+ this.dashboardModelSnapshotKey = snapshotKey;
359
373
  this.dashboardSessionsCache = this.dashboardPendingActions.applyToSessions(dashSessions);
360
374
  this.dashboardServicesCache = this.dashboardPendingActions.applyToServices(dashServices);
361
375
  this.dashboardWorktreeGroupsCache = worktreeGroups;
362
376
  this.dashboardMainCheckoutInfoCache = mainCheckoutInfo;
363
377
  this.dashboardModelRefreshedAt = Date.now();
364
378
  this.dashboardUiStateStore.markSelectionDirty();
379
+ return true;
365
380
  }
366
381
  invalidateDesktopStateSnapshot() {
367
382
  this.desktopStateSnapshot = null;
@@ -503,10 +518,9 @@ export class Multiplexer {
503
518
  }
504
519
  computeDashboardServices(worktrees = this.listDesktopWorktrees()) {
505
520
  const lastUsedState = loadLastUsedState(process.cwd());
506
- const tmuxSession = this.tmuxRuntimeManager.getProjectSession(process.cwd());
507
521
  const worktreeByPath = new Map(worktrees.map((wt) => [wt.path, wt]));
508
- return this.tmuxRuntimeManager
509
- .listManagedWindows(tmuxSession.sessionName)
522
+ const liveServices = this.tmuxRuntimeManager
523
+ .listProjectManagedWindows(process.cwd())
510
524
  .filter(({ target, metadata }) => !isDashboardWindowName(target.windowName) && metadata.kind === "service")
511
525
  .map(({ target, metadata }) => {
512
526
  const worktree = metadata.worktreePath ? worktreeByPath.get(metadata.worktreePath) : undefined;
@@ -530,6 +544,30 @@ export class Multiplexer {
530
544
  previewLine: info.previewLine,
531
545
  };
532
546
  });
547
+ const liveIds = new Set(liveServices.map((service) => service.id));
548
+ const offlineServices = this.offlineServices
549
+ .filter((service) => !liveIds.has(service.id))
550
+ .map((service) => {
551
+ const worktree = service.worktreePath ? worktreeByPath.get(service.worktreePath) : undefined;
552
+ const label = service.label ?? this.serviceLabelForCommand(service.launchCommandLine ?? "");
553
+ const previewLine = service.launchCommandLine?.trim() || "Interactive shell";
554
+ return {
555
+ id: service.id,
556
+ command: service.launchCommandLine?.trim() ?? "",
557
+ args: [],
558
+ lastUsedAt: lastUsedState.items[service.id]?.lastUsedAt,
559
+ worktreePath: service.worktreePath,
560
+ worktreeName: worktree?.name,
561
+ worktreeBranch: worktree?.branch,
562
+ status: "offline",
563
+ active: false,
564
+ label,
565
+ cwd: service.worktreePath,
566
+ foregroundCommand: label,
567
+ previewLine,
568
+ };
569
+ });
570
+ return [...liveServices, ...offlineServices];
533
571
  }
534
572
  readTmuxProcessInfo(target) {
535
573
  const raw = this.tmuxRuntimeManager.displayMessage("#{pane_current_command}\t#{pane_pid}", target.windowId) ?? "";
@@ -595,8 +633,7 @@ export class Multiplexer {
595
633
  const dashServices = body.services ?? [];
596
634
  const worktrees = body.worktrees ?? [];
597
635
  const worktreeGroups = this.buildDashboardWorktreeGroups(dashSessions, dashServices, worktrees, body.mainCheckoutPath);
598
- this.applyDashboardModel(dashSessions, dashServices, worktreeGroups, body.mainCheckoutInfo ?? { name: "Main Checkout", branch: "" });
599
- return true;
636
+ return this.applyDashboardModel(dashSessions, dashServices, worktreeGroups, body.mainCheckoutInfo ?? { name: "Main Checkout", branch: "" });
600
637
  }
601
638
  }
602
639
  catch {
@@ -638,6 +675,8 @@ export class Multiplexer {
638
675
  removeWorktree: ({ path }) => this.removeDesktopWorktree(path),
639
676
  createService: ({ command, worktreePath }) => this.createService(command ?? "", worktreePath),
640
677
  stopService: ({ serviceId }) => this.stopService(serviceId),
678
+ resumeService: ({ serviceId }) => this.resumeOfflineServiceById(serviceId),
679
+ removeService: ({ serviceId }) => this.removeOfflineService(serviceId),
641
680
  listGraveyard: () => this.listGraveyardEntries(),
642
681
  resurrectGraveyard: ({ sessionId }) => this.resurrectGraveyardSession(sessionId),
643
682
  },
@@ -1437,14 +1476,14 @@ export class Multiplexer {
1437
1476
  finalArgs = [...finalArgs, ...expandedFlag];
1438
1477
  }
1439
1478
  const toolCfg = toolConfigKey ? loadConfig().tools[toolConfigKey] : undefined;
1479
+ let projectRoot = process.cwd();
1480
+ try {
1481
+ projectRoot = findMainRepo(worktreePath ?? process.cwd());
1482
+ }
1483
+ catch {
1484
+ projectRoot = process.cwd();
1485
+ }
1440
1486
  if (toolCfg && toolConfigKey === "claude" && toolCfg.command === command && toolCfg.wrapperEnabled !== false) {
1441
- let projectRoot = process.cwd();
1442
- try {
1443
- projectRoot = findMainRepo(worktreePath ?? process.cwd());
1444
- }
1445
- catch {
1446
- projectRoot = process.cwd();
1447
- }
1448
1487
  finalArgs = injectClaudeHookArgs(finalArgs, {
1449
1488
  sessionId,
1450
1489
  projectRoot,
@@ -1452,6 +1491,17 @@ export class Multiplexer {
1452
1491
  });
1453
1492
  launchCommand = toolCfg.command;
1454
1493
  }
1494
+ else if (toolCfg && toolCfg.command === command) {
1495
+ const wrapped = wrapCommandWithShellIntegration({
1496
+ projectRoot,
1497
+ sessionId,
1498
+ tool: toolConfigKey ?? command,
1499
+ command: launchCommand,
1500
+ args: finalArgs,
1501
+ });
1502
+ launchCommand = wrapped.command;
1503
+ finalArgs = wrapped.args;
1504
+ }
1455
1505
  if (preambleFlag) {
1456
1506
  this.sessionBootstrap.finalizePreamble(command, preamble);
1457
1507
  }
@@ -1558,6 +1608,11 @@ export class Multiplexer {
1558
1608
  const sid = this.sessions[index].id;
1559
1609
  this.sessionMRU = [sid, ...this.sessionMRU.filter((id) => id !== sid)];
1560
1610
  this.agentTracker.markSeen(sid);
1611
+ updateNotificationContext("tui", {
1612
+ focused: true,
1613
+ sessionId: sid,
1614
+ panelOpen: false,
1615
+ });
1561
1616
  this.noteLastUsedItem(sid);
1562
1617
  markNotificationsRead({ sessionId: sid });
1563
1618
  this.syncTuiNotificationContext(false);
@@ -1740,13 +1795,19 @@ export class Multiplexer {
1740
1795
  const selectedService = this.getSelectedDashboardServiceForActions();
1741
1796
  if (selectedService) {
1742
1797
  try {
1743
- this.stopService(selectedService.id);
1744
- this.footerFlash = `◆ Stopped service ${selectedService.label ?? selectedService.id}`;
1798
+ if (selectedService.status === "offline") {
1799
+ this.removeOfflineService(selectedService.id);
1800
+ this.footerFlash = `◆ Deleted service ${selectedService.label ?? selectedService.id}`;
1801
+ }
1802
+ else {
1803
+ this.stopService(selectedService.id);
1804
+ this.footerFlash = `◆ Stopped service ${selectedService.label ?? selectedService.id}`;
1805
+ }
1745
1806
  this.footerFlashTicks = 3;
1746
1807
  this.renderDashboard();
1747
1808
  }
1748
1809
  catch (error) {
1749
- this.showDashboardError("Failed to stop service", [error instanceof Error ? error.message : String(error)]);
1810
+ this.showDashboardError(selectedService.status === "offline" ? "Failed to delete service" : "Failed to stop service", [error instanceof Error ? error.message : String(error)]);
1750
1811
  }
1751
1812
  return;
1752
1813
  }
@@ -1829,7 +1890,10 @@ export class Multiplexer {
1829
1890
  case "enter": {
1830
1891
  const ds = this.getDashboardSessions();
1831
1892
  const entry = ds[this.activeIndex];
1832
- if (entry && this.openLiveTmuxWindowForEntry(entry)) {
1893
+ if (entry?.pendingAction === "creating" || entry?.pendingAction === "starting") {
1894
+ return;
1895
+ }
1896
+ if (entry && this.openLiveTmuxWindowForEntry(entry) !== "missing") {
1833
1897
  return;
1834
1898
  }
1835
1899
  if (entry?.remoteInstanceId) {
@@ -1838,7 +1902,7 @@ export class Multiplexer {
1838
1902
  }
1839
1903
  if (entry?.status === "offline") {
1840
1904
  const offline = this.offlineSessions.find((s) => s.id === entry.id);
1841
- if (offline && entry.pendingAction !== "starting") {
1905
+ if (offline) {
1842
1906
  void this.resumeOfflineSessionWithFeedback(offline);
1843
1907
  }
1844
1908
  return;
@@ -1923,13 +1987,38 @@ export class Multiplexer {
1923
1987
  if (!selectedEntry)
1924
1988
  break;
1925
1989
  if (selectedEntry.kind === "service") {
1926
- this.openLiveTmuxWindowForService(selectedEntry.id);
1990
+ const service = this.getDashboardServices().find((entry) => entry.id === selectedEntry.id);
1991
+ if (!service)
1992
+ break;
1993
+ if (service.pendingAction === "creating" || service.pendingAction === "starting") {
1994
+ return;
1995
+ }
1996
+ if (service.status === "offline") {
1997
+ try {
1998
+ this.resumeOfflineServiceById(service.id);
1999
+ this.footerFlash = `◆ Started service ${service.label ?? service.id}`;
2000
+ this.footerFlashTicks = 3;
2001
+ this.renderDashboard();
2002
+ }
2003
+ catch (error) {
2004
+ this.showDashboardError("Failed to start service", [
2005
+ error instanceof Error ? error.message : String(error),
2006
+ ]);
2007
+ }
2008
+ return;
2009
+ }
2010
+ if (this.openLiveTmuxWindowForService(selectedEntry.id) !== "missing") {
2011
+ return;
2012
+ }
1927
2013
  break;
1928
2014
  }
1929
2015
  const dashEntry = this.dashboardState.worktreeSessions.find((entry) => entry.id === selectedEntry.id);
1930
2016
  if (!dashEntry)
1931
2017
  break;
1932
- if (this.openLiveTmuxWindowForEntry(dashEntry)) {
2018
+ if (dashEntry.pendingAction === "creating" || dashEntry.pendingAction === "starting") {
2019
+ return;
2020
+ }
2021
+ if (this.openLiveTmuxWindowForEntry(dashEntry) !== "missing") {
1933
2022
  return;
1934
2023
  }
1935
2024
  if (dashEntry.remoteInstanceId) {
@@ -1938,7 +2027,7 @@ export class Multiplexer {
1938
2027
  }
1939
2028
  if (dashEntry.status === "offline") {
1940
2029
  const offline = this.offlineSessions.find((s) => s.id === dashEntry.id);
1941
- if (offline && dashEntry.pendingAction !== "starting") {
2030
+ if (offline) {
1942
2031
  void this.resumeOfflineSessionWithFeedback(offline);
1943
2032
  }
1944
2033
  return;
@@ -1968,10 +2057,10 @@ export class Multiplexer {
1968
2057
  async activateDashboardEntry(entry) {
1969
2058
  if (!entry)
1970
2059
  return;
1971
- if (entry.pendingAction === "creating") {
2060
+ if (entry.pendingAction === "creating" || entry.pendingAction === "starting") {
1972
2061
  return;
1973
2062
  }
1974
- if (this.openLiveTmuxWindowForEntry(entry)) {
2063
+ if (this.openLiveTmuxWindowForEntry(entry) !== "missing") {
1975
2064
  return;
1976
2065
  }
1977
2066
  if (entry.remoteInstanceId) {
@@ -1980,7 +2069,7 @@ export class Multiplexer {
1980
2069
  }
1981
2070
  if (entry.status === "offline") {
1982
2071
  const offline = this.offlineSessions.find((session) => session.id === entry.id);
1983
- if (offline && entry.pendingAction !== "starting") {
2072
+ if (offline) {
1984
2073
  await this.resumeOfflineSessionWithFeedback(offline);
1985
2074
  }
1986
2075
  return;
@@ -2910,19 +2999,42 @@ export class Multiplexer {
2910
2999
  return false;
2911
3000
  }
2912
3001
  openLiveTmuxWindowForEntry(entry) {
2913
- const target = openManagedSessionWindow(this.tmuxRuntimeManager, process.cwd(), entry);
2914
- if (!target)
2915
- return false;
2916
- this.agentTracker.markSeen(entry.id);
2917
- this.noteLastUsedItem(entry.id);
2918
- return true;
3002
+ try {
3003
+ const target = openManagedSessionWindow(this.tmuxRuntimeManager, process.cwd(), entry);
3004
+ if (!target)
3005
+ return "missing";
3006
+ this.agentTracker.markSeen(entry.id);
3007
+ updateNotificationContext("tui", {
3008
+ focused: true,
3009
+ sessionId: entry.id,
3010
+ panelOpen: false,
3011
+ });
3012
+ this.noteLastUsedItem(entry.id);
3013
+ return "opened";
3014
+ }
3015
+ catch (error) {
3016
+ this.showDashboardError("Failed to open agent", [
3017
+ error instanceof Error ? error.message : String(error),
3018
+ "The tmux window may still be starting. Try again in a moment.",
3019
+ ]);
3020
+ return "error";
3021
+ }
2919
3022
  }
2920
3023
  openLiveTmuxWindowForService(serviceId) {
2921
- const target = openManagedServiceWindow(this.tmuxRuntimeManager, process.cwd(), serviceId);
2922
- if (!target)
2923
- return false;
2924
- this.noteLastUsedItem(serviceId);
2925
- return true;
3024
+ try {
3025
+ const target = openManagedServiceWindow(this.tmuxRuntimeManager, process.cwd(), serviceId);
3026
+ if (!target)
3027
+ return "missing";
3028
+ this.noteLastUsedItem(serviceId);
3029
+ return "opened";
3030
+ }
3031
+ catch (error) {
3032
+ this.showDashboardError("Failed to open service", [
3033
+ error instanceof Error ? error.message : String(error),
3034
+ "The tmux window may still be starting. Try again in a moment.",
3035
+ ]);
3036
+ return "error";
3037
+ }
2926
3038
  }
2927
3039
  noteLastUsedItem(itemId) {
2928
3040
  markLastUsed(process.cwd(), {
@@ -3715,8 +3827,30 @@ export class Multiplexer {
3715
3827
  const cwd = worktreePath ?? process.cwd();
3716
3828
  const shell = process.env.SHELL || "zsh";
3717
3829
  const trimmed = commandLine.trim();
3718
- const command = shell;
3719
- const args = trimmed ? ["-lc", trimmed] : ["-l"];
3830
+ let projectRoot = process.cwd();
3831
+ try {
3832
+ projectRoot = findMainRepo(cwd);
3833
+ }
3834
+ catch {
3835
+ projectRoot = process.cwd();
3836
+ }
3837
+ const wrapped = trimmed
3838
+ ? wrapCommandWithShellIntegration({
3839
+ projectRoot,
3840
+ sessionId: serviceId,
3841
+ tool: "service",
3842
+ command: shell,
3843
+ args: ["-lc", trimmed],
3844
+ shellPath: shell,
3845
+ })
3846
+ : wrapInteractiveShellWithIntegration({
3847
+ projectRoot,
3848
+ sessionId: serviceId,
3849
+ tool: "service",
3850
+ shellPath: shell,
3851
+ });
3852
+ const command = wrapped.command;
3853
+ const args = wrapped.args;
3720
3854
  const label = this.serviceLabelForCommand(trimmed);
3721
3855
  const tmuxSession = this.tmuxRuntimeManager.ensureProjectSession(process.cwd());
3722
3856
  const shouldRenderPending = this.startedInDashboard && this.mode === "dashboard";
@@ -3730,13 +3864,14 @@ export class Multiplexer {
3730
3864
  this.tmuxRuntimeManager.setWindowMetadata(target, {
3731
3865
  kind: "service",
3732
3866
  sessionId: serviceId,
3733
- command,
3734
- args,
3867
+ command: trimmed ? shell : "shell",
3868
+ args: trimmed ? ["-lc", trimmed] : ["-l"],
3735
3869
  toolConfigKey: "service",
3736
3870
  worktreePath,
3737
3871
  label,
3738
3872
  });
3739
3873
  this.tmuxRuntimeManager.applyManagedAgentWindowPolicy(target, "service");
3874
+ this.saveState();
3740
3875
  this.invalidateDesktopStateSnapshot();
3741
3876
  this.refreshLocalDashboardModel();
3742
3877
  this.updateWorktreeSessions();
@@ -3759,12 +3894,112 @@ export class Multiplexer {
3759
3894
  if (!match || match.metadata.kind !== "service") {
3760
3895
  throw new Error(`Service "${serviceId}" not found`);
3761
3896
  }
3897
+ const launchCommandLine = match.metadata.command === "shell"
3898
+ ? ""
3899
+ : match.metadata.args?.[0] === "-lc"
3900
+ ? (match.metadata.args[1] ?? "")
3901
+ : "";
3902
+ this.offlineServices = [
3903
+ ...this.offlineServices.filter((service) => service.id !== serviceId),
3904
+ {
3905
+ id: serviceId,
3906
+ worktreePath: match.metadata.worktreePath,
3907
+ label: match.metadata.label,
3908
+ launchCommandLine,
3909
+ },
3910
+ ];
3762
3911
  this.tmuxRuntimeManager.killWindow(match.target);
3912
+ this.saveState();
3763
3913
  this.invalidateDesktopStateSnapshot();
3764
3914
  this.refreshLocalDashboardModel();
3765
3915
  this.adjustAfterRemove(this.dashboardWorktreeGroupsCache.length > 0);
3766
3916
  return { serviceId, status: "stopped" };
3767
3917
  }
3918
+ removeOfflineService(serviceId) {
3919
+ this.offlineServices = this.offlineServices.filter((service) => service.id !== serviceId);
3920
+ const statePath = getStatePath();
3921
+ if (existsSync(statePath)) {
3922
+ try {
3923
+ const state = JSON.parse(readFileSync(statePath, "utf-8"));
3924
+ state.services = (state.services ?? []).filter((service) => service.id !== serviceId);
3925
+ writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
3926
+ }
3927
+ catch { }
3928
+ }
3929
+ this.saveState();
3930
+ this.invalidateDesktopStateSnapshot();
3931
+ this.refreshLocalDashboardModel();
3932
+ this.adjustAfterRemove(this.dashboardWorktreeGroupsCache.length > 0);
3933
+ return { serviceId, status: "removed" };
3934
+ }
3935
+ resumeOfflineService(service) {
3936
+ const existing = this.tmuxRuntimeManager.findManagedWindow(this.tmuxRuntimeManager.getProjectSession(process.cwd()).sessionName, {
3937
+ sessionId: service.id,
3938
+ });
3939
+ if (existing && existing.metadata.kind === "service") {
3940
+ this.offlineServices = this.offlineServices.filter((entry) => entry.id !== service.id);
3941
+ this.saveState();
3942
+ this.invalidateDesktopStateSnapshot();
3943
+ this.refreshLocalDashboardModel();
3944
+ return { serviceId: service.id, status: "running" };
3945
+ }
3946
+ const cwd = service.worktreePath ?? process.cwd();
3947
+ const shell = process.env.SHELL || "zsh";
3948
+ const launchCommandLine = service.launchCommandLine?.trim() ?? "";
3949
+ let projectRoot = process.cwd();
3950
+ try {
3951
+ projectRoot = findMainRepo(cwd);
3952
+ }
3953
+ catch {
3954
+ projectRoot = process.cwd();
3955
+ }
3956
+ const wrapped = launchCommandLine
3957
+ ? wrapCommandWithShellIntegration({
3958
+ projectRoot,
3959
+ sessionId: service.id,
3960
+ tool: "service",
3961
+ command: shell,
3962
+ args: ["-lc", launchCommandLine],
3963
+ shellPath: shell,
3964
+ })
3965
+ : wrapInteractiveShellWithIntegration({
3966
+ projectRoot,
3967
+ sessionId: service.id,
3968
+ tool: "service",
3969
+ shellPath: shell,
3970
+ });
3971
+ const command = wrapped.command;
3972
+ const args = wrapped.args;
3973
+ const label = service.label ?? this.serviceLabelForCommand(launchCommandLine);
3974
+ const tmuxSession = this.tmuxRuntimeManager.ensureProjectSession(process.cwd());
3975
+ const target = this.tmuxRuntimeManager.createWindow(tmuxSession.sessionName, label, cwd, command, args, {
3976
+ detached: true,
3977
+ });
3978
+ this.tmuxRuntimeManager.setWindowMetadata(target, {
3979
+ kind: "service",
3980
+ sessionId: service.id,
3981
+ command: launchCommandLine ? shell : "shell",
3982
+ args: launchCommandLine ? ["-lc", launchCommandLine] : ["-l"],
3983
+ toolConfigKey: "service",
3984
+ worktreePath: service.worktreePath,
3985
+ label,
3986
+ });
3987
+ this.tmuxRuntimeManager.applyManagedAgentWindowPolicy(target, "service");
3988
+ this.offlineServices = this.offlineServices.filter((entry) => entry.id !== service.id);
3989
+ this.saveState();
3990
+ this.invalidateDesktopStateSnapshot();
3991
+ this.refreshLocalDashboardModel();
3992
+ this.updateWorktreeSessions();
3993
+ this.preferDashboardEntrySelection("service", service.id, service.worktreePath);
3994
+ return { serviceId: service.id, status: "running" };
3995
+ }
3996
+ resumeOfflineServiceById(serviceId) {
3997
+ const service = this.offlineServices.find((entry) => entry.id === serviceId);
3998
+ if (!service) {
3999
+ throw new Error(`Service "${serviceId}" not found`);
4000
+ }
4001
+ return this.resumeOfflineService(service);
4002
+ }
3768
4003
  renderDashboard() {
3769
4004
  this.writeStatuslineFile();
3770
4005
  const { cols, rows } = this.getViewportSize();
@@ -5395,6 +5630,7 @@ export class Multiplexer {
5395
5630
  syncSessionsFromState(state = Multiplexer.loadState()) {
5396
5631
  this.restoreTmuxSessionsFromState(state);
5397
5632
  this.loadOfflineSessions(state);
5633
+ this.loadOfflineServices(state);
5398
5634
  this.invalidateDesktopStateSnapshot();
5399
5635
  }
5400
5636
  loadOfflineSessions(state = Multiplexer.loadState()) {
@@ -5434,12 +5670,57 @@ export class Multiplexer {
5434
5670
  }
5435
5671
  return previousKey !== nextKey;
5436
5672
  }
5673
+ loadOfflineServices(state = Multiplexer.loadState()) {
5674
+ const savedServices = state?.services ?? [];
5675
+ if (savedServices.length === 0) {
5676
+ const changed = this.offlineServices.length > 0;
5677
+ this.offlineServices = [];
5678
+ return changed;
5679
+ }
5680
+ const liveServiceIds = new Set(this.tmuxRuntimeManager
5681
+ .listProjectManagedWindows(process.cwd())
5682
+ .filter(({ target, metadata }) => !isDashboardWindowName(target.windowName) && metadata.kind === "service")
5683
+ .map(({ metadata }) => metadata.sessionId));
5684
+ const nextOfflineServices = savedServices.filter((service) => {
5685
+ if (liveServiceIds.has(service.id))
5686
+ return false;
5687
+ if (service.worktreePath && !existsSync(service.worktreePath))
5688
+ return false;
5689
+ return true;
5690
+ });
5691
+ const previousKey = this.offlineServices
5692
+ .map((service) => `${service.id}:${service.label ?? ""}:${service.worktreePath ?? ""}:${service.launchCommandLine ?? ""}`)
5693
+ .join("|");
5694
+ const nextKey = nextOfflineServices
5695
+ .map((service) => `${service.id}:${service.label ?? ""}:${service.worktreePath ?? ""}:${service.launchCommandLine ?? ""}`)
5696
+ .join("|");
5697
+ this.offlineServices = nextOfflineServices;
5698
+ return previousKey !== nextKey;
5699
+ }
5700
+ buildLiveServiceStates() {
5701
+ const seen = new Set();
5702
+ const liveServices = [];
5703
+ for (const { metadata } of this.tmuxRuntimeManager.listProjectManagedWindows(process.cwd())) {
5704
+ if (metadata.kind !== "service")
5705
+ continue;
5706
+ if (seen.has(metadata.sessionId))
5707
+ continue;
5708
+ seen.add(metadata.sessionId);
5709
+ const launchCommandLine = metadata.command === "shell" ? "" : metadata.args?.[0] === "-lc" ? (metadata.args[1] ?? "") : "";
5710
+ liveServices.push({
5711
+ id: metadata.sessionId,
5712
+ worktreePath: metadata.worktreePath,
5713
+ label: metadata.label,
5714
+ launchCommandLine,
5715
+ });
5716
+ }
5717
+ return liveServices;
5718
+ }
5437
5719
  restoreTmuxSessionsFromState(state = Multiplexer.loadState()) {
5438
5720
  const savedById = new Map((state?.sessions ?? []).map((session) => [session.id, session]));
5439
5721
  const cols = process.stdout.columns ?? 80;
5440
5722
  const rows = process.stdout.rows ?? 24;
5441
- const tmuxSession = this.tmuxRuntimeManager.getProjectSession(process.cwd());
5442
- for (const { target, metadata } of this.tmuxRuntimeManager.listManagedWindows(tmuxSession.sessionName)) {
5723
+ for (const { target, metadata } of this.tmuxRuntimeManager.listProjectManagedWindows(process.cwd())) {
5443
5724
  if (isDashboardWindowName(target.windowName))
5444
5725
  continue;
5445
5726
  if (metadata.kind === "service")
@@ -5687,11 +5968,14 @@ export class Multiplexer {
5687
5968
  tmuxTarget: this.sessionTmuxTargets.get(s.id),
5688
5969
  }));
5689
5970
  const mySessions = [...this.offlineSessions, ...liveSessions];
5690
- if (mySessions.length === 0)
5971
+ const liveServices = this.buildLiveServiceStates();
5972
+ const myServices = [...this.offlineServices, ...liveServices].filter((service, index, services) => services.findIndex((entry) => entry.id === service.id) === index);
5973
+ if (mySessions.length === 0 && myServices.length === 0)
5691
5974
  return;
5692
5975
  // Merge with existing state (other instances may have written their sessions)
5693
5976
  const statePath = Multiplexer.getSharedStatePath();
5694
5977
  let mergedSessions = mySessions;
5978
+ let mergedServices = myServices;
5695
5979
  if (existsSync(statePath)) {
5696
5980
  try {
5697
5981
  const existing = JSON.parse(readFileSync(statePath, "utf-8"));
@@ -5706,6 +5990,9 @@ export class Multiplexer {
5706
5990
  return true;
5707
5991
  });
5708
5992
  mergedSessions = [...otherSessions, ...mySessions];
5993
+ const myServiceIds = new Set(myServices.map((service) => service.id));
5994
+ const otherServices = (existing.services ?? []).filter((service) => !myServiceIds.has(service.id));
5995
+ mergedServices = [...otherServices, ...myServices];
5709
5996
  }
5710
5997
  catch {
5711
5998
  // Corrupt file — just overwrite with ours
@@ -5715,6 +6002,7 @@ export class Multiplexer {
5715
6002
  savedAt: new Date().toISOString(),
5716
6003
  cwd: process.cwd(),
5717
6004
  sessions: mergedSessions,
6005
+ services: mergedServices,
5718
6006
  };
5719
6007
  writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
5720
6008
  this.invalidateDesktopStateSnapshot();