aimux-cli 0.1.1 → 0.1.3

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 (50) hide show
  1. package/dist/daemon.js +35 -3
  2. package/dist/daemon.js.map +1 -1
  3. package/dist/dashboard-feedback.d.ts +38 -0
  4. package/dist/dashboard-feedback.js +113 -0
  5. package/dist/dashboard-feedback.js.map +1 -0
  6. package/dist/dashboard-pending-actions.d.ts +12 -0
  7. package/dist/dashboard-pending-actions.js +52 -0
  8. package/dist/dashboard-pending-actions.js.map +1 -0
  9. package/dist/dashboard-session-actions.d.ts +29 -0
  10. package/dist/dashboard-session-actions.js +82 -0
  11. package/dist/dashboard-session-actions.js.map +1 -0
  12. package/dist/dashboard-targets.d.ts +18 -0
  13. package/dist/dashboard-targets.js +236 -0
  14. package/dist/dashboard-targets.js.map +1 -0
  15. package/dist/dashboard-ui-state-store.d.ts +12 -0
  16. package/dist/dashboard-ui-state-store.js +106 -0
  17. package/dist/dashboard-ui-state-store.js.map +1 -0
  18. package/dist/dashboard.d.ts +3 -1
  19. package/dist/dashboard.js +4 -0
  20. package/dist/dashboard.js.map +1 -1
  21. package/dist/main.js +41 -207
  22. package/dist/main.js.map +1 -1
  23. package/dist/metadata-server.js +6 -77
  24. package/dist/metadata-server.js.map +1 -1
  25. package/dist/multiplexer-runtime-sync.d.ts +26 -0
  26. package/dist/multiplexer-runtime-sync.js +58 -0
  27. package/dist/multiplexer-runtime-sync.js.map +1 -0
  28. package/dist/multiplexer.d.ts +25 -10
  29. package/dist/multiplexer.js +353 -274
  30. package/dist/multiplexer.js.map +1 -1
  31. package/dist/paths.d.ts +1 -0
  32. package/dist/paths.js +3 -0
  33. package/dist/paths.js.map +1 -1
  34. package/dist/statusline-model.js +1 -0
  35. package/dist/statusline-model.js.map +1 -1
  36. package/dist/tmux-runtime-manager.d.ts +0 -8
  37. package/dist/tmux-runtime-manager.js +34 -36
  38. package/dist/tmux-runtime-manager.js.map +1 -1
  39. package/dist/tmux-switcher.d.ts +11 -0
  40. package/dist/tmux-switcher.js +115 -0
  41. package/dist/tmux-switcher.js.map +1 -0
  42. package/dist/tmux-window-open.d.ts +9 -0
  43. package/dist/tmux-window-open.js +71 -0
  44. package/dist/tmux-window-open.js.map +1 -0
  45. package/dist/tool-output-watchers.d.ts +14 -1
  46. package/dist/tool-output-watchers.js +18 -7
  47. package/dist/tool-output-watchers.js.map +1 -1
  48. package/dist/tui/screens/dashboard-renderers.js +2 -1
  49. package/dist/tui/screens/dashboard-renderers.js.map +1 -1
  50. package/package.json +1 -1
@@ -25,7 +25,7 @@ import { TmuxRuntimeManager } from "./tmux-runtime-manager.js";
25
25
  import { isDashboardWindowName } from "./tmux-runtime-manager.js";
26
26
  import { TmuxSessionTransport } from "./tmux-session-transport.js";
27
27
  import { MetadataServer } from "./metadata-server.js";
28
- import { loadMetadataState, removeMetadataEndpoint, resolveProjectServiceEndpoint } from "./metadata-store.js";
28
+ import { loadMetadataState, removeMetadataEndpoint, resolveProjectServiceEndpoint, updateSessionMetadata, } from "./metadata-store.js";
29
29
  import { PluginRuntime } from "./plugin-runtime.js";
30
30
  import { SessionBootstrapService } from "./session-bootstrap.js";
31
31
  import { ensureDaemonRunning, ensureProjectService, loadDaemonInfo } from "./daemon.js";
@@ -43,6 +43,7 @@ import { deriveSessionSemantics } from "./session-semantics.js";
43
43
  import { injectClaudeHookArgs } from "./claude-hooks.js";
44
44
  import { navigationUrgencyScore } from "./fast-control.js";
45
45
  import { requestJson } from "./http-client.js";
46
+ import { openDashboardTarget } from "./dashboard-targets.js";
46
47
  import { clearNotifications, listNotifications, markNotificationsRead, } from "./notifications.js";
47
48
  import { updateNotificationContext } from "./notification-context.js";
48
49
  import { buildThreadEntries, buildWorkflowEntries, describeWorkflowNextAction, filterWorkflowEntries, } from "./workflow.js";
@@ -50,7 +51,14 @@ import { renderActivityScreen, renderGraveyardDetails, renderGraveyardScreen, re
50
51
  import { renderDashboardBusyOverlay, renderDashboardErrorOverlay, renderHelpOverlay, renderLabelInputOverlay, renderMigratePickerOverlay, renderNotificationPanel, renderServiceInputOverlay, renderSwitcherOverlay, renderWorktreeListOverlay, renderWorktreeRemoveConfirmOverlay, } from "./tui/screens/overlay-renderers.js";
51
52
  import { composeTwoPane, stripAnsi, truncateAnsi, truncatePlain, wrapKeyValue, wrapText } from "./tui/render/text.js";
52
53
  import { loadStatusline, renderTmuxStatuslineFromData } from "./tmux-statusline.js";
54
+ import { DashboardUiStateStore } from "./dashboard-ui-state-store.js";
55
+ import { DashboardPendingActions } from "./dashboard-pending-actions.js";
56
+ import { DashboardFeedbackController, } from "./dashboard-feedback.js";
57
+ import { MultiplexerRuntimeSync } from "./multiplexer-runtime-sync.js";
58
+ import { openManagedServiceWindow, openManagedSessionWindow, selectLinkedOrOpenTarget } from "./tmux-window-open.js";
59
+ import { graveyardSessionWithFeedback as runGraveyardSessionWithFeedback, resumeOfflineSessionWithFeedback as runResumeOfflineSessionWithFeedback, stopSessionToOfflineWithFeedback as runStopSessionToOfflineWithFeedback, waitForSessionExit, waitForSessionStart, } from "./dashboard-session-actions.js";
53
60
  export class Multiplexer {
61
+ projectRoot;
54
62
  sessions = [];
55
63
  activeIndex = 0;
56
64
  mode = "dashboard";
@@ -83,9 +91,10 @@ export class Multiplexer {
83
91
  worktreeListActive = false;
84
92
  worktreeRemoveConfirm = null;
85
93
  worktreeRemovalJob = null;
86
- dashboardBusyState = null;
87
- dashboardBusySpinner = null;
88
- dashboardErrorState = null;
94
+ dashboardFeedback = new DashboardFeedbackController({
95
+ renderDashboard: () => this.renderDashboard(),
96
+ isDashboardMode: () => this.mode === "dashboard",
97
+ });
89
98
  migratePickerActive = false;
90
99
  migratePickerWorktrees = [];
91
100
  graveyardEntries = [];
@@ -102,7 +111,11 @@ export class Multiplexer {
102
111
  planEntries = [];
103
112
  planIndex = 0;
104
113
  notificationPanelState = null;
105
- pendingDashboardSessionActions = new Map();
114
+ dashboardPendingActions = new DashboardPendingActions(() => {
115
+ if (this.mode === "dashboard") {
116
+ this.renderCurrentDashboardView();
117
+ }
118
+ });
106
119
  stoppingSessionIds = new Set();
107
120
  graveyardAfterStopSessionIds = new Set();
108
121
  /** Quick switcher overlay state */
@@ -115,8 +128,8 @@ export class Multiplexer {
115
128
  confirmedRegistered = new Set();
116
129
  /** The focused worktree path on the dashboard (undefined = main repo) */
117
130
  dashboardState = new DashboardState();
131
+ dashboardUiStateStore = new DashboardUiStateStore();
118
132
  statusInterval = null;
119
- heartbeatInterval = null;
120
133
  agentTracker = new AgentTracker();
121
134
  instanceId = randomUUID();
122
135
  contextWatcher = new ContextWatcher((target) => this.tmuxRuntimeManager.captureTarget(target, { startLine: -120 }));
@@ -148,7 +161,6 @@ export class Multiplexer {
148
161
  metadataServer = null;
149
162
  eventBus = new ProjectEventBus();
150
163
  pluginRuntime = null;
151
- projectServiceInterval = null;
152
164
  lastRenderedFrame = null;
153
165
  lastStatuslineSnapshotKey = null;
154
166
  desktopStateSnapshot = null;
@@ -159,10 +171,37 @@ export class Multiplexer {
159
171
  dashboardModelRefreshedAt = 0;
160
172
  dashboardServiceSnapshotRefreshing = false;
161
173
  dashboardServiceRecovery = null;
174
+ dashboardNextBackgroundRefreshAt = 0;
175
+ runtimeSync;
162
176
  constructor() {
177
+ this.projectRoot = (() => {
178
+ try {
179
+ return findMainRepo(process.cwd());
180
+ }
181
+ catch {
182
+ return process.cwd();
183
+ }
184
+ })();
163
185
  this.terminalHost = new TerminalHost();
164
186
  this.hotkeys = new HotkeyHandler((action) => this.handleAction(action));
165
187
  this.dashboard = new Dashboard();
188
+ this.runtimeSync = new MultiplexerRuntimeSync({
189
+ instanceDirectory: this.instanceDirectory,
190
+ instanceId: this.instanceId,
191
+ cwd: process.cwd(),
192
+ getMode: () => this.mode,
193
+ getConfirmedRegistered: () => this.confirmedRegistered,
194
+ setConfirmedRegistered: (value) => {
195
+ this.confirmedRegistered = value;
196
+ },
197
+ getInstanceSessionRefs: () => this.getInstanceSessionRefs(),
198
+ syncSessionsFromState: () => this.syncSessionsFromState(),
199
+ loadOfflineSessions: () => this.loadOfflineSessions(),
200
+ renderCurrentDashboardView: () => this.renderCurrentDashboardView(),
201
+ renderDashboard: () => this.renderDashboard(),
202
+ handleSessionClaimed: (sessionId) => this.handleSessionClaimed(sessionId),
203
+ writeStatuslineFile: () => this.writeStatuslineFile(),
204
+ });
166
205
  this.eventBus.subscribe((event) => {
167
206
  if (event.type !== "alert")
168
207
  return;
@@ -219,10 +258,7 @@ export class Multiplexer {
219
258
  return true;
220
259
  }
221
260
  openTmuxDashboardTarget() {
222
- const session = this.tmuxRuntimeManager.ensureProjectSession(process.cwd());
223
- const openSession = this.tmuxRuntimeManager.getOpenSessionName(session.sessionName);
224
- const target = this.tmuxRuntimeManager.ensureDashboardWindow(openSession, process.cwd());
225
- this.tmuxRuntimeManager.openTarget(target, { insideTmux: this.tmuxRuntimeManager.isInsideTmux() });
261
+ openDashboardTarget(this.projectRoot, this.tmuxRuntimeManager);
226
262
  }
227
263
  invalidateDashboardFrame() {
228
264
  this.lastRenderedFrame = null;
@@ -232,14 +268,20 @@ export class Multiplexer {
232
268
  }
233
269
  handleDashboardFocusIn() {
234
270
  this.terminalHost.enterAlternateScreen();
235
- this.invalidateDashboardFrame();
236
- this.renderCurrentDashboardView();
237
- void this.refreshDashboardModelFromService(true).then((updated) => {
238
- if (!updated || this.mode !== "dashboard")
239
- return;
240
- this.terminalHost.enterAlternateScreen();
241
- this.invalidateDashboardFrame();
242
- this.renderCurrentDashboardView();
271
+ if (this.lastRenderedFrame) {
272
+ process.stdout.write(this.lastRenderedFrame);
273
+ }
274
+ this.tmuxRuntimeManager.refreshStatus();
275
+ }
276
+ loadDashboardUiState() {
277
+ this.dashboardUiStateStore.loadInto(this.dashboardState);
278
+ }
279
+ persistDashboardUiState() {
280
+ this.dashboardUiStateStore.persist(this.mode, this.dashboardState, this.activeIndex, this.getDashboardSessions());
281
+ }
282
+ restoreDashboardSelectionFromPreference(dashSessions, hasWorktrees) {
283
+ this.dashboardUiStateStore.consumeSelectionRestore(this.dashboardState, dashSessions, hasWorktrees, () => this.updateWorktreeSessions(), this.activeIndex, (value) => {
284
+ this.activeIndex = value;
243
285
  });
244
286
  }
245
287
  writeFrame(output, force = false) {
@@ -314,25 +356,12 @@ export class Multiplexer {
314
356
  });
315
357
  }
316
358
  applyDashboardModel(dashSessions, dashServices, worktreeGroups, mainCheckoutInfo) {
317
- this.dashboardSessionsCache = this.applyPendingDashboardSessionStates(dashSessions);
318
- this.dashboardServicesCache = dashServices;
359
+ this.dashboardSessionsCache = this.dashboardPendingActions.applyToSessions(dashSessions);
360
+ this.dashboardServicesCache = this.dashboardPendingActions.applyToServices(dashServices);
319
361
  this.dashboardWorktreeGroupsCache = worktreeGroups;
320
362
  this.dashboardMainCheckoutInfoCache = mainCheckoutInfo;
321
363
  this.dashboardModelRefreshedAt = Date.now();
322
- }
323
- applyPendingDashboardSessionStates(sessions) {
324
- if (this.pendingDashboardSessionActions.size === 0)
325
- return sessions;
326
- return sessions.map((session) => {
327
- const pendingAction = this.pendingDashboardSessionActions.get(session.id);
328
- if (!pendingAction)
329
- return session;
330
- return {
331
- ...session,
332
- pendingAction,
333
- optimistic: true,
334
- };
335
- });
364
+ this.dashboardUiStateStore.markSelectionDirty();
336
365
  }
337
366
  invalidateDesktopStateSnapshot() {
338
367
  this.desktopStateSnapshot = null;
@@ -805,11 +834,43 @@ export class Multiplexer {
805
834
  delete offline.label;
806
835
  }
807
836
  }
837
+ applyDashboardSessionLabel(sessionId, label) {
838
+ const trimmed = label?.trim();
839
+ this.dashboardSessionsCache = this.dashboardSessionsCache.map((session) => session.id === sessionId ? { ...session, label: trimmed || undefined } : session);
840
+ this.dashboardWorktreeGroupsCache = this.dashboardWorktreeGroupsCache.map((group) => ({
841
+ ...group,
842
+ sessions: group.sessions.map((session) => session.id === sessionId ? { ...session, label: trimmed || undefined } : session),
843
+ }));
844
+ this.dashboardState.worktreeSessions = this.dashboardState.worktreeSessions.map((session) => session.id === sessionId ? { ...session, label: trimmed || undefined } : session);
845
+ }
808
846
  async updateSessionLabel(sessionId, label) {
847
+ if (this.mode === "dashboard") {
848
+ this.applySessionLabel(sessionId, label);
849
+ this.applyDashboardSessionLabel(sessionId, label);
850
+ this.setPendingDashboardSessionAction(sessionId, "renaming");
851
+ this.writeStatuslineFile();
852
+ this.renderCurrentDashboardView();
853
+ void this.postToProjectService("/agents/rename", { sessionId, label })
854
+ .then(() => {
855
+ this.invalidateDesktopStateSnapshot();
856
+ this.setPendingDashboardSessionAction(sessionId, null);
857
+ this.writeStatuslineFile();
858
+ this.renderCurrentDashboardView();
859
+ })
860
+ .catch((err) => {
861
+ this.setPendingDashboardSessionAction(sessionId, null);
862
+ this.footerFlash = `Rename failed: ${err instanceof Error ? err.message : String(err)}`;
863
+ this.footerFlashTicks = 4;
864
+ this.writeStatuslineFile();
865
+ this.renderCurrentDashboardView();
866
+ });
867
+ return;
868
+ }
809
869
  this.applySessionLabel(sessionId, label);
870
+ this.invalidateDesktopStateSnapshot();
810
871
  const localSession = this.sessions.find((session) => session.id === sessionId)?.transport;
811
872
  if (localSession instanceof TmuxSessionTransport) {
812
- localSession.renameWindow(label?.trim() || localSession.command);
873
+ localSession.renameWindow(localSession.command);
813
874
  const target = localSession.tmuxTarget;
814
875
  this.sessionTmuxTargets.set(sessionId, target);
815
876
  this.syncTmuxWindowMetadata(sessionId);
@@ -1221,6 +1282,7 @@ export class Multiplexer {
1221
1282
  process.stdout.on("resize", this.onResize);
1222
1283
  // Enter dashboard mode directly
1223
1284
  this.mode = "dashboard";
1285
+ this.loadDashboardUiState();
1224
1286
  const primed = await this.refreshDashboardModelFromService(true);
1225
1287
  if (!primed) {
1226
1288
  throw new Error("dashboard requires a live project service desktop-state endpoint");
@@ -1408,17 +1470,12 @@ export class Multiplexer {
1408
1470
  if (session instanceof TmuxSessionTransport) {
1409
1471
  this.syncTmuxWindowMetadata(sessionId);
1410
1472
  }
1411
- // Focus the new session
1412
1473
  this.activeIndex = this.sessions.length - 1;
1413
1474
  if (this.startedInDashboard && this.mode === "dashboard") {
1414
1475
  this.invalidateDesktopStateSnapshot();
1415
1476
  this.refreshLocalDashboardModel();
1416
1477
  this.updateWorktreeSessions();
1417
- this.dashboardState.level = "sessions";
1418
- const selectedIndex = this.dashboardState.worktreeEntries.findIndex((entry) => entry.kind === "session" && entry.id === sessionId);
1419
- if (selectedIndex >= 0) {
1420
- this.dashboardState.sessionIndex = selectedIndex;
1421
- }
1478
+ this.preferDashboardEntrySelection("session", sessionId, worktreePath);
1422
1479
  this.renderDashboard();
1423
1480
  }
1424
1481
  this.saveState();
@@ -1507,18 +1564,7 @@ export class Multiplexer {
1507
1564
  const target = this.sessionTmuxTargets.get(sid);
1508
1565
  if (target) {
1509
1566
  this.saveState();
1510
- const insideTmux = this.tmuxRuntimeManager.isInsideTmux();
1511
- if (insideTmux) {
1512
- const currentClientSession = this.tmuxRuntimeManager.currentClientSession();
1513
- if (currentClientSession) {
1514
- const linkedTarget = this.tmuxRuntimeManager.getTargetByWindowId(currentClientSession, target.windowId);
1515
- if (linkedTarget) {
1516
- this.tmuxRuntimeManager.selectWindow(linkedTarget);
1517
- return;
1518
- }
1519
- }
1520
- }
1521
- this.tmuxRuntimeManager.openTarget(target, { insideTmux });
1567
+ selectLinkedOrOpenTarget(this.tmuxRuntimeManager, target);
1522
1568
  }
1523
1569
  }
1524
1570
  handleAction(action) {
@@ -1717,13 +1763,18 @@ export class Multiplexer {
1717
1763
  : undefined;
1718
1764
  if (!selEntry)
1719
1765
  return;
1720
- if (selEntry.status === "offline" || selEntry.pendingAction === "stopping") {
1766
+ const runtime = this.sessions.find((s) => s.id === selEntry.id);
1767
+ const effectivelyOffline = selEntry.status === "offline" ||
1768
+ selEntry.pendingAction === "stopping" ||
1769
+ !runtime ||
1770
+ !this.isSessionRuntimeLive(runtime);
1771
+ if (effectivelyOffline) {
1721
1772
  // Second [x] on offline → move to graveyard
1722
1773
  void this.graveyardSessionWithFeedback(selEntry.id, hasWorktrees);
1723
1774
  return;
1724
1775
  }
1725
1776
  // First [x] on running → stop PTY, keep as offline for resume
1726
- const pty = this.sessions.find((s) => s.id === selEntry.id);
1777
+ const pty = runtime;
1727
1778
  if (pty) {
1728
1779
  void this.stopSessionToOfflineWithFeedback(pty);
1729
1780
  }
@@ -1787,7 +1838,7 @@ export class Multiplexer {
1787
1838
  }
1788
1839
  if (entry?.status === "offline") {
1789
1840
  const offline = this.offlineSessions.find((s) => s.id === entry.id);
1790
- if (offline) {
1841
+ if (offline && entry.pendingAction !== "starting") {
1791
1842
  void this.resumeOfflineSessionWithFeedback(offline);
1792
1843
  }
1793
1844
  return;
@@ -1887,7 +1938,7 @@ export class Multiplexer {
1887
1938
  }
1888
1939
  if (dashEntry.status === "offline") {
1889
1940
  const offline = this.offlineSessions.find((s) => s.id === dashEntry.id);
1890
- if (offline) {
1941
+ if (offline && dashEntry.pendingAction !== "starting") {
1891
1942
  void this.resumeOfflineSessionWithFeedback(offline);
1892
1943
  }
1893
1944
  return;
@@ -1917,6 +1968,9 @@ export class Multiplexer {
1917
1968
  async activateDashboardEntry(entry) {
1918
1969
  if (!entry)
1919
1970
  return;
1971
+ if (entry.pendingAction === "creating") {
1972
+ return;
1973
+ }
1920
1974
  if (this.openLiveTmuxWindowForEntry(entry)) {
1921
1975
  return;
1922
1976
  }
@@ -1926,7 +1980,7 @@ export class Multiplexer {
1926
1980
  }
1927
1981
  if (entry.status === "offline") {
1928
1982
  const offline = this.offlineSessions.find((session) => session.id === entry.id);
1929
- if (offline) {
1983
+ if (offline && entry.pendingAction !== "starting") {
1930
1984
  await this.resumeOfflineSessionWithFeedback(offline);
1931
1985
  }
1932
1986
  return;
@@ -2671,6 +2725,7 @@ export class Multiplexer {
2671
2725
  this.dashboardState.setScreen(screen);
2672
2726
  this.syncTuiNotificationContext(false);
2673
2727
  this.writeDashboardClientStatuslineFile();
2728
+ this.persistDashboardUiState();
2674
2729
  this.tmuxRuntimeManager.refreshStatus();
2675
2730
  }
2676
2731
  handleActiveDashboardOverlayKey(data) {
@@ -2855,27 +2910,18 @@ export class Multiplexer {
2855
2910
  return false;
2856
2911
  }
2857
2912
  openLiveTmuxWindowForEntry(entry) {
2858
- const tmuxSession = this.tmuxRuntimeManager.getProjectSession(process.cwd());
2859
- const match = this.tmuxRuntimeManager.findManagedWindow(tmuxSession.sessionName, {
2860
- sessionId: entry.id,
2861
- backendSessionId: entry.backendSessionId,
2862
- });
2863
- if (!match)
2913
+ const target = openManagedSessionWindow(this.tmuxRuntimeManager, process.cwd(), entry);
2914
+ if (!target)
2864
2915
  return false;
2865
2916
  this.agentTracker.markSeen(entry.id);
2866
2917
  this.noteLastUsedItem(entry.id);
2867
- this.tmuxRuntimeManager.openTarget(match.target, { insideTmux: this.tmuxRuntimeManager.isInsideTmux() });
2868
2918
  return true;
2869
2919
  }
2870
2920
  openLiveTmuxWindowForService(serviceId) {
2871
- const tmuxSession = this.tmuxRuntimeManager.getProjectSession(process.cwd());
2872
- const match = this.tmuxRuntimeManager.findManagedWindow(tmuxSession.sessionName, {
2873
- sessionId: serviceId,
2874
- });
2875
- if (!match || match.metadata.kind !== "service")
2921
+ const target = openManagedServiceWindow(this.tmuxRuntimeManager, process.cwd(), serviceId);
2922
+ if (!target)
2876
2923
  return false;
2877
2924
  this.noteLastUsedItem(serviceId);
2878
- this.tmuxRuntimeManager.openTarget(match.target, { insideTmux: this.tmuxRuntimeManager.isInsideTmux() });
2879
2925
  return true;
2880
2926
  }
2881
2927
  noteLastUsedItem(itemId) {
@@ -3367,7 +3413,21 @@ export class Multiplexer {
3367
3413
  }
3368
3414
  this.pickerMode = "create";
3369
3415
  this.forkSourceSessionId = null;
3370
- this.createSession(tool.command, tool.args, tool.preambleFlag, toolKey, undefined, tool.sessionIdFlag, wtPath);
3416
+ const sessionId = this.generateDashboardSessionId(tool.command);
3417
+ const shouldRenderPending = this.startedInDashboard && this.mode === "dashboard";
3418
+ if (shouldRenderPending) {
3419
+ this.setPendingDashboardSessionAction(sessionId, "creating");
3420
+ }
3421
+ try {
3422
+ this.createSession(tool.command, tool.args, tool.preambleFlag, toolKey, undefined, tool.sessionIdFlag, wtPath, undefined, sessionId, shouldRenderPending);
3423
+ this.settleDashboardCreatePending(sessionId);
3424
+ }
3425
+ catch (error) {
3426
+ if (shouldRenderPending) {
3427
+ this.setPendingDashboardSessionAction(sessionId, null);
3428
+ }
3429
+ throw error;
3430
+ }
3371
3431
  }
3372
3432
  showToolPicker(sourceSessionId) {
3373
3433
  const config = loadConfig();
@@ -3530,7 +3590,9 @@ export class Multiplexer {
3530
3590
  throw new Error(`Unable to fork session ${opts.sourceSessionId}`);
3531
3591
  }
3532
3592
  if (opts.open !== false && result.target) {
3533
- this.tmuxRuntimeManager.openTarget(result.target, { insideTmux: this.tmuxRuntimeManager.isInsideTmux() });
3593
+ if (!this.openLiveTmuxWindowForEntry({ id: result.sessionId })) {
3594
+ this.tmuxRuntimeManager.openTarget(result.target, { insideTmux: this.tmuxRuntimeManager.isInsideTmux() });
3595
+ }
3534
3596
  }
3535
3597
  return {
3536
3598
  sessionId: result.sessionId,
@@ -3554,7 +3616,9 @@ export class Multiplexer {
3554
3616
  const transport = this.createSession(toolCfg.command, toolCfg.args, toolCfg.preambleFlag, opts.toolConfigKey, undefined, toolCfg.sessionIdFlag, targetWorktreePath);
3555
3617
  const target = this.sessionTmuxTargets.get(transport.id);
3556
3618
  if (opts.open !== false && target) {
3557
- this.tmuxRuntimeManager.openTarget(target, { insideTmux: this.tmuxRuntimeManager.isInsideTmux() });
3619
+ if (!this.openLiveTmuxWindowForEntry({ id: transport.id })) {
3620
+ this.tmuxRuntimeManager.openTarget(target, { insideTmux: this.tmuxRuntimeManager.isInsideTmux() });
3621
+ }
3558
3622
  }
3559
3623
  return { sessionId: transport.id };
3560
3624
  }
@@ -3581,7 +3645,7 @@ export class Multiplexer {
3581
3645
  if (!this.stoppingSessionIds.has(sessionId)) {
3582
3646
  this.stopSessionToOffline(runningSession);
3583
3647
  }
3584
- await this.waitForSessionExit(runningSession);
3648
+ await waitForSessionExit(runningSession);
3585
3649
  this.saveState();
3586
3650
  return { sessionId, status: "offline" };
3587
3651
  }
@@ -3595,7 +3659,7 @@ export class Multiplexer {
3595
3659
  if (!this.stoppingSessionIds.has(sessionId)) {
3596
3660
  this.stopSessionToOffline(runningSession);
3597
3661
  }
3598
- await this.waitForSessionExit(runningSession);
3662
+ await waitForSessionExit(runningSession);
3599
3663
  this.saveState();
3600
3664
  }
3601
3665
  else {
@@ -3620,7 +3684,7 @@ export class Multiplexer {
3620
3684
  throw new Error(`Session "${sessionId}" not found`);
3621
3685
  }
3622
3686
  await this.migrateAgent(sessionId, targetWorktreePath);
3623
- await this.waitForSessionExit(runningSession);
3687
+ await waitForSessionExit(runningSession);
3624
3688
  return { sessionId, worktreePath: this.getSessionWorktreePath(sessionId) };
3625
3689
  }
3626
3690
  serviceLabelForCommand(commandLine) {
@@ -3630,6 +3694,22 @@ export class Multiplexer {
3630
3694
  const first = trimmed.split(/\s+/)[0] ?? "service";
3631
3695
  return basename(first);
3632
3696
  }
3697
+ generateDashboardSessionId(command) {
3698
+ return `${command}-${Math.random().toString(36).slice(2, 8)}`;
3699
+ }
3700
+ settleDashboardCreatePending(itemId) {
3701
+ if (!(this.startedInDashboard && this.mode === "dashboard"))
3702
+ return;
3703
+ this.dashboardPendingActions.settleCreatePending(itemId, () => {
3704
+ this.refreshLocalDashboardModel();
3705
+ this.renderDashboard();
3706
+ });
3707
+ }
3708
+ preferDashboardEntrySelection(kind, id, worktreePath) {
3709
+ if (!(this.startedInDashboard && this.mode === "dashboard"))
3710
+ return;
3711
+ this.dashboardUiStateStore.preferEntrySelection(this.dashboardState, kind, id, worktreePath);
3712
+ }
3633
3713
  createService(commandLine, worktreePath) {
3634
3714
  const serviceId = `service-${randomUUID().slice(0, 8)}`;
3635
3715
  const cwd = worktreePath ?? process.cwd();
@@ -3639,28 +3719,37 @@ export class Multiplexer {
3639
3719
  const args = trimmed ? ["-lc", trimmed] : ["-l"];
3640
3720
  const label = this.serviceLabelForCommand(trimmed);
3641
3721
  const tmuxSession = this.tmuxRuntimeManager.ensureProjectSession(process.cwd());
3642
- const target = this.tmuxRuntimeManager.createWindow(tmuxSession.sessionName, label, cwd, command, args, {
3643
- detached: true,
3644
- });
3645
- this.tmuxRuntimeManager.setWindowMetadata(target, {
3646
- kind: "service",
3647
- sessionId: serviceId,
3648
- command,
3649
- args,
3650
- toolConfigKey: "service",
3651
- worktreePath,
3652
- label,
3653
- });
3654
- this.tmuxRuntimeManager.applyManagedAgentWindowPolicy(target, "service");
3655
- this.invalidateDesktopStateSnapshot();
3656
- this.refreshLocalDashboardModel();
3657
- this.updateWorktreeSessions();
3658
- this.dashboardState.level = "sessions";
3659
- const selectedIndex = this.dashboardState.worktreeEntries.findIndex((entry) => entry.kind === "service" && entry.id === serviceId);
3660
- if (selectedIndex >= 0) {
3661
- this.dashboardState.sessionIndex = selectedIndex;
3722
+ const shouldRenderPending = this.startedInDashboard && this.mode === "dashboard";
3723
+ if (shouldRenderPending) {
3724
+ this.setPendingDashboardSessionAction(serviceId, "creating");
3725
+ }
3726
+ try {
3727
+ const target = this.tmuxRuntimeManager.createWindow(tmuxSession.sessionName, label, cwd, command, args, {
3728
+ detached: true,
3729
+ });
3730
+ this.tmuxRuntimeManager.setWindowMetadata(target, {
3731
+ kind: "service",
3732
+ sessionId: serviceId,
3733
+ command,
3734
+ args,
3735
+ toolConfigKey: "service",
3736
+ worktreePath,
3737
+ label,
3738
+ });
3739
+ this.tmuxRuntimeManager.applyManagedAgentWindowPolicy(target, "service");
3740
+ this.invalidateDesktopStateSnapshot();
3741
+ this.refreshLocalDashboardModel();
3742
+ this.updateWorktreeSessions();
3743
+ this.preferDashboardEntrySelection("service", serviceId, worktreePath);
3744
+ this.settleDashboardCreatePending(serviceId);
3745
+ return { serviceId };
3746
+ }
3747
+ catch (error) {
3748
+ if (shouldRenderPending) {
3749
+ this.setPendingDashboardSessionAction(serviceId, null);
3750
+ }
3751
+ throw error;
3662
3752
  }
3663
- return { serviceId };
3664
3753
  }
3665
3754
  stopService(serviceId) {
3666
3755
  const tmuxSession = this.tmuxRuntimeManager.getProjectSession(process.cwd());
@@ -3689,7 +3778,9 @@ export class Multiplexer {
3689
3778
  // Ensure focusedWorktreePath is valid
3690
3779
  if (!this.dashboardState.worktreeNavOrder.includes(this.dashboardState.focusedWorktreePath)) {
3691
3780
  this.dashboardState.focusedWorktreePath = undefined;
3781
+ this.dashboardUiStateStore.markSelectionDirty();
3692
3782
  }
3783
+ this.restoreDashboardSelectionFromPreference(dashSessions, hasWorktrees);
3693
3784
  // Determine selected session for cursor
3694
3785
  let selectedSession;
3695
3786
  let selectedService;
@@ -3708,6 +3799,7 @@ export class Multiplexer {
3708
3799
  this.dashboard.update(dashSessions, dashServices, worktreeGroups, this.dashboardState.focusedWorktreePath, hasWorktrees ? this.dashboardState.level : "sessions", selectedSession, selectedService, "tmux", mainCheckoutInfo);
3709
3800
  this.syncTuiNotificationContext(Boolean(this.notificationPanelState));
3710
3801
  this.writeFrame(this.dashboard.render(cols, rows));
3802
+ this.persistDashboardUiState();
3711
3803
  if (this.dashboardBusyState) {
3712
3804
  this.renderDashboardBusyOverlay();
3713
3805
  }
@@ -3992,49 +4084,19 @@ export class Multiplexer {
3992
4084
  }
3993
4085
  }
3994
4086
  startDashboardBusy(title, lines) {
3995
- this.dashboardErrorState = null;
3996
- this.dashboardBusyState = {
3997
- title,
3998
- lines,
3999
- startedAt: Date.now(),
4000
- spinnerFrame: 0,
4001
- };
4002
- if (this.dashboardBusySpinner) {
4003
- clearInterval(this.dashboardBusySpinner);
4004
- }
4005
- this.dashboardBusySpinner = setInterval(() => {
4006
- if (!this.dashboardBusyState)
4007
- return;
4008
- this.dashboardBusyState.spinnerFrame = (this.dashboardBusyState.spinnerFrame + 1) % 10;
4009
- if (this.mode === "dashboard")
4010
- this.renderDashboard();
4011
- }, 120);
4012
- this.footerFlash = null;
4013
- this.footerFlashTicks = 0;
4014
- this.renderDashboard();
4087
+ this.dashboardFeedback.startBusy(title, lines);
4015
4088
  }
4016
4089
  updateDashboardBusy(lines) {
4017
- if (!this.dashboardBusyState)
4018
- return;
4019
- this.dashboardBusyState.lines = lines;
4020
- if (this.mode === "dashboard")
4021
- this.renderDashboard();
4090
+ this.dashboardFeedback.updateBusy(lines);
4022
4091
  }
4023
4092
  clearDashboardBusy() {
4024
- if (this.dashboardBusySpinner) {
4025
- clearInterval(this.dashboardBusySpinner);
4026
- this.dashboardBusySpinner = null;
4027
- }
4028
- this.dashboardBusyState = null;
4093
+ this.dashboardFeedback.clearBusy();
4029
4094
  }
4030
4095
  showDashboardError(title, lines) {
4031
- this.clearDashboardBusy();
4032
- this.dashboardErrorState = { title, lines };
4033
- this.renderDashboard();
4096
+ this.dashboardFeedback.showError(title, lines);
4034
4097
  }
4035
4098
  dismissDashboardError() {
4036
- this.dashboardErrorState = null;
4037
- this.renderDashboard();
4099
+ this.dashboardFeedback.dismissError();
4038
4100
  }
4039
4101
  beginWorktreeRemoval(path, name, oldIdx) {
4040
4102
  if (this.worktreeRemovalJob)
@@ -4609,67 +4671,14 @@ export class Multiplexer {
4609
4671
  renderMigratePicker() {
4610
4672
  renderMigratePickerOverlay(this);
4611
4673
  }
4612
- waitForSessionExit(session, timeoutMs = 15_000) {
4613
- if (session.exited)
4614
- return Promise.resolve();
4615
- return new Promise((resolve, reject) => {
4616
- const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${session.id} to exit`)), timeoutMs);
4617
- session.onExit(() => {
4618
- clearTimeout(timer);
4619
- resolve();
4620
- });
4621
- });
4622
- }
4623
4674
  async runDashboardOperation(title, lines, work, errorTitle = title) {
4624
- this.startDashboardBusy(title, lines);
4625
- const minVisibleMs = 250;
4626
- const startedAt = Date.now();
4627
- try {
4628
- const result = await work();
4629
- const remaining = minVisibleMs - (Date.now() - startedAt);
4630
- if (remaining > 0) {
4631
- await new Promise((resolve) => setTimeout(resolve, remaining));
4632
- }
4633
- this.clearDashboardBusy();
4634
- return result;
4635
- }
4636
- catch (err) {
4637
- const message = err instanceof Error ? err.message : String(err);
4638
- this.showDashboardError(errorTitle, [message]);
4639
- return undefined;
4640
- }
4675
+ return this.dashboardFeedback.runOperation(title, lines, work, errorTitle);
4641
4676
  }
4642
4677
  setPendingDashboardSessionAction(sessionId, kind) {
4643
- if (kind) {
4644
- this.pendingDashboardSessionActions.set(sessionId, kind);
4645
- }
4646
- else {
4647
- this.pendingDashboardSessionActions.delete(sessionId);
4648
- }
4649
- if (this.mode === "dashboard") {
4650
- this.refreshLocalDashboardModel();
4651
- this.renderDashboard();
4652
- }
4678
+ this.dashboardPendingActions.set(sessionId, kind);
4653
4679
  }
4654
4680
  async stopSessionToOfflineWithFeedback(session) {
4655
- const label = this.getSessionLabel(session.id) ?? session.command;
4656
- this.setPendingDashboardSessionAction(session.id, "stopping");
4657
- try {
4658
- this.stopSessionToOffline(session);
4659
- await this.waitForSessionExit(session);
4660
- if (!this.graveyardAfterStopSessionIds.has(session.id)) {
4661
- this.setPendingDashboardSessionAction(session.id, null);
4662
- }
4663
- this.refreshLocalDashboardModel();
4664
- this.footerFlash = `Stopped ${label}`;
4665
- this.footerFlashTicks = 3;
4666
- this.renderDashboard();
4667
- }
4668
- catch (err) {
4669
- this.setPendingDashboardSessionAction(session.id, null);
4670
- const message = err instanceof Error ? err.message : String(err);
4671
- this.showDashboardError(`Failed to stop "${label}"`, [message]);
4672
- }
4681
+ await runStopSessionToOfflineWithFeedback(this.dashboardSessionActionDeps(), session);
4673
4682
  }
4674
4683
  clearDashboardSubscreens() {
4675
4684
  this.dashboardState.resetSubscreen();
@@ -4778,30 +4787,34 @@ export class Multiplexer {
4778
4787
  }
4779
4788
  async graveyardSessionWithFeedback(sessionId, hasWorktrees) {
4780
4789
  const session = this.offlineSessions.find((s) => s.id === sessionId) ?? this.sessions.find((s) => s.id === sessionId);
4781
- if (!session)
4782
- return;
4783
- const label = ("label" in session ? session.label : this.getSessionLabel(sessionId)) ?? session.command;
4784
- this.setPendingDashboardSessionAction(sessionId, "graveyarding");
4785
- try {
4786
- await this.sendAgentToGraveyard(sessionId);
4787
- this.setPendingDashboardSessionAction(sessionId, null);
4788
- this.adjustAfterRemove(hasWorktrees);
4789
- this.footerFlash = `Sent ${label} to graveyard`;
4790
- this.footerFlashTicks = 3;
4791
- this.renderDashboard();
4792
- }
4793
- catch (err) {
4794
- this.setPendingDashboardSessionAction(sessionId, null);
4795
- const message = err instanceof Error ? err.message : String(err);
4796
- this.showDashboardError(`Failed to graveyard "${label}"`, [message]);
4797
- }
4790
+ await runGraveyardSessionWithFeedback(this.dashboardSessionActionDeps(), session, sessionId, hasWorktrees);
4798
4791
  }
4799
4792
  async resumeOfflineSessionWithFeedback(session) {
4800
- const label = session.label ?? session.command;
4801
- await this.runDashboardOperation(`Restoring "${label}"`, [` Session: ${session.id}`], () => {
4802
- this.resumeOfflineSession(session);
4803
- this.focusSession(this.sessions.length - 1);
4804
- }, `Failed to restore "${label}"`);
4793
+ await runResumeOfflineSessionWithFeedback(this.dashboardSessionActionDeps(), session);
4794
+ }
4795
+ async waitForSessionStart(sessionId, timeoutMs = 8000) {
4796
+ return waitForSessionStart(sessionId, this.dashboardSessionActionDeps(), timeoutMs);
4797
+ }
4798
+ dashboardSessionActionDeps() {
4799
+ return {
4800
+ getSessionLabel: (sessionId) => this.getSessionLabel(sessionId),
4801
+ getPendingAction: (sessionId) => this.dashboardPendingActions.get(sessionId),
4802
+ setPendingAction: (sessionId, kind) => this.setPendingDashboardSessionAction(sessionId, kind),
4803
+ stopSessionToOffline: (session) => this.stopSessionToOffline(session),
4804
+ isGraveyardAfterStop: (sessionId) => this.graveyardAfterStopSessionIds.has(sessionId),
4805
+ sendAgentToGraveyard: (sessionId) => this.sendAgentToGraveyard(sessionId).then(() => undefined),
4806
+ resumeOfflineSession: (session) => this.resumeOfflineSession(session),
4807
+ refreshLocalDashboardModel: () => this.refreshLocalDashboardModel(),
4808
+ adjustAfterRemove: (hasWorktrees) => this.adjustAfterRemove(hasWorktrees),
4809
+ renderDashboard: () => this.renderDashboard(),
4810
+ showDashboardError: (title, lines) => this.showDashboardError(title, lines),
4811
+ setFooterFlash: (message, ticks) => {
4812
+ this.footerFlash = message;
4813
+ this.footerFlashTicks = ticks;
4814
+ },
4815
+ getRuntimeById: (sessionId) => this.sessions.find((session) => session.id === sessionId),
4816
+ isSessionRuntimeLive: (session) => this.isSessionRuntimeLive(session),
4817
+ };
4805
4818
  }
4806
4819
  async takeoverFromDashEntryWithFeedback(entry) {
4807
4820
  const label = entry.label ?? entry.command;
@@ -4811,7 +4824,7 @@ export class Multiplexer {
4811
4824
  const label = this.getSessionLabel(session.id) ?? session.command;
4812
4825
  await this.runDashboardOperation(`Migrating "${label}"`, [` From: ${this.sessionWorktreePaths.get(session.id) ?? "(main)"}`, ` To: ${targetName}`], async () => {
4813
4826
  await this.migrateAgent(session.id, targetPath);
4814
- await this.waitForSessionExit(session);
4827
+ await waitForSessionExit(session);
4815
4828
  this.renderDashboard();
4816
4829
  }, `Failed to migrate "${label}"`);
4817
4830
  }
@@ -5097,7 +5110,7 @@ export class Multiplexer {
5097
5110
  label: session.label,
5098
5111
  tmuxWindowId: session.tmuxWindowId,
5099
5112
  tmuxWindowIndex: session.tmuxWindowIndex,
5100
- windowName: session.label || session.command,
5113
+ windowName: session.command,
5101
5114
  headline: session.headline,
5102
5115
  status: session.status,
5103
5116
  role: session.role,
@@ -5112,7 +5125,7 @@ export class Multiplexer {
5112
5125
  label: service.label,
5113
5126
  tmuxWindowId: service.tmuxWindowId,
5114
5127
  tmuxWindowIndex: service.tmuxWindowIndex,
5115
- windowName: service.label || service.command,
5128
+ windowName: service.command,
5116
5129
  headline: service.previewLine,
5117
5130
  status: service.status,
5118
5131
  active: service.active,
@@ -5273,12 +5286,35 @@ export class Multiplexer {
5273
5286
  /** Track previous statuses for notification on transition */
5274
5287
  prevStatuses = new Map();
5275
5288
  /** Flash message shown temporarily in footer, cleared after a few renders */
5276
- footerFlash = null;
5277
- footerFlashTicks = 0;
5289
+ get dashboardBusyState() {
5290
+ return this.dashboardFeedback.busyState;
5291
+ }
5292
+ set dashboardBusyState(value) {
5293
+ this.dashboardFeedback.busyState = value;
5294
+ }
5295
+ get dashboardErrorState() {
5296
+ return this.dashboardFeedback.errorState;
5297
+ }
5298
+ set dashboardErrorState(value) {
5299
+ this.dashboardFeedback.errorState = value;
5300
+ }
5301
+ get footerFlash() {
5302
+ return this.dashboardFeedback.flash;
5303
+ }
5304
+ set footerFlash(value) {
5305
+ this.dashboardFeedback.flash = value;
5306
+ }
5307
+ get footerFlashTicks() {
5308
+ return this.dashboardFeedback.flashTicks;
5309
+ }
5310
+ set footerFlashTicks(value) {
5311
+ this.dashboardFeedback.flashTicks = value;
5312
+ }
5278
5313
  startStatusRefresh() {
5279
5314
  if (this.statusInterval)
5280
5315
  return;
5281
5316
  this.statusInterval = setInterval(() => {
5317
+ let dashboardNeedsRender = false;
5282
5318
  if (this.mode === "project-service") {
5283
5319
  this.taskDispatcher?.tick(this.sessions.map((s) => s.id));
5284
5320
  this.orchestrationDispatcher?.tick(this.sessions.map((s) => s.id));
@@ -5305,18 +5341,19 @@ export class Multiplexer {
5305
5341
  this.footerFlash = `↻ Changes requested: ${ev.description}`;
5306
5342
  }
5307
5343
  this.footerFlashTicks = 3;
5344
+ dashboardNeedsRender = true;
5308
5345
  }
5309
5346
  const orchestrationEvents = this.orchestrationDispatcher?.drainEvents() ?? [];
5310
5347
  for (const event of orchestrationEvents) {
5311
5348
  if (event.type === "message_delivered") {
5312
5349
  this.footerFlash = `✉ Message delivered → ${event.sessionId}`;
5313
5350
  this.footerFlashTicks = 3;
5351
+ dashboardNeedsRender = true;
5314
5352
  }
5315
5353
  }
5316
- if (this.footerFlashTicks > 0)
5317
- this.footerFlashTicks--;
5318
- if (this.footerFlashTicks === 0)
5319
- this.footerFlash = null;
5354
+ if (this.dashboardFeedback.tickFlashVisibilityChanged()) {
5355
+ dashboardNeedsRender = true;
5356
+ }
5320
5357
  for (const session of this.sessions) {
5321
5358
  const prev = this.prevStatuses.get(session.id);
5322
5359
  const curr = session.status;
@@ -5333,8 +5370,18 @@ export class Multiplexer {
5333
5370
  this.prevStatuses.set(session.id, curr);
5334
5371
  }
5335
5372
  if (this.mode === "dashboard") {
5336
- void this.refreshDashboardModelFromService();
5337
- this.renderCurrentDashboardView();
5373
+ const now = Date.now();
5374
+ if (now >= this.dashboardNextBackgroundRefreshAt) {
5375
+ this.dashboardNextBackgroundRefreshAt = now + 5000;
5376
+ void this.refreshDashboardModelFromService().then((refreshed) => {
5377
+ if (refreshed || dashboardNeedsRender) {
5378
+ this.renderCurrentDashboardView();
5379
+ }
5380
+ });
5381
+ }
5382
+ else if (dashboardNeedsRender) {
5383
+ this.renderCurrentDashboardView();
5384
+ }
5338
5385
  }
5339
5386
  }, 1000);
5340
5387
  }
@@ -5351,8 +5398,11 @@ export class Multiplexer {
5351
5398
  this.invalidateDesktopStateSnapshot();
5352
5399
  }
5353
5400
  loadOfflineSessions(state = Multiplexer.loadState()) {
5354
- if (!state || state.sessions.length === 0)
5355
- return;
5401
+ if (!state || state.sessions.length === 0) {
5402
+ const changed = this.offlineSessions.length > 0;
5403
+ this.offlineSessions = [];
5404
+ return changed;
5405
+ }
5356
5406
  // Get all session IDs owned by live instances (including ourselves)
5357
5407
  const ownedIds = new Set();
5358
5408
  for (const s of this.sessions)
@@ -5363,7 +5413,7 @@ export class Multiplexer {
5363
5413
  }
5364
5414
  // Also exclude by backendSessionId to catch resumed sessions with new IDs
5365
5415
  const ownedBackendIds = new Set(this.sessions.map((session) => session.backendSessionId).filter((value) => Boolean(value)));
5366
- this.offlineSessions = state.sessions.filter((s) => {
5416
+ const nextOfflineSessions = state.sessions.filter((s) => {
5367
5417
  if (ownedIds.has(s.id))
5368
5418
  return false;
5369
5419
  if (s.backendSessionId && ownedBackendIds.has(s.backendSessionId))
@@ -5372,9 +5422,17 @@ export class Multiplexer {
5372
5422
  return false;
5373
5423
  return true;
5374
5424
  });
5425
+ const previousKey = this.offlineSessions
5426
+ .map((session) => `${session.id}:${session.label ?? ""}:${session.worktreePath ?? ""}`)
5427
+ .join("|");
5428
+ const nextKey = nextOfflineSessions
5429
+ .map((session) => `${session.id}:${session.label ?? ""}:${session.worktreePath ?? ""}`)
5430
+ .join("|");
5431
+ this.offlineSessions = nextOfflineSessions;
5375
5432
  if (this.offlineSessions.length > 0) {
5376
5433
  debug(`loaded ${this.offlineSessions.length} offline session(s) from state.json`, "session");
5377
5434
  }
5435
+ return previousKey !== nextKey;
5378
5436
  }
5379
5437
  restoreTmuxSessionsFromState(state = Multiplexer.loadState()) {
5380
5438
  const savedById = new Map((state?.sessions ?? []).map((session) => [session.id, session]));
@@ -5396,7 +5454,9 @@ export class Multiplexer {
5396
5454
  const label = metadata.label ?? saved?.label;
5397
5455
  if (label) {
5398
5456
  this.sessionLabels.set(metadata.sessionId, label);
5399
- transport.renameWindow(label);
5457
+ }
5458
+ if (target.windowName !== metadata.command) {
5459
+ transport.renameWindow(metadata.command);
5400
5460
  }
5401
5461
  this.syncTmuxWindowMetadata(metadata.sessionId);
5402
5462
  }
@@ -5478,55 +5538,85 @@ export class Multiplexer {
5478
5538
  }
5479
5539
  debug(`graveyarded session ${sessionId}`, "session");
5480
5540
  }
5541
+ isSessionRuntimeLive(runtime) {
5542
+ if (runtime.exited)
5543
+ return false;
5544
+ const mappedTarget = this.sessionTmuxTargets.get(runtime.id);
5545
+ const runtimeTarget = runtime.transport instanceof TmuxSessionTransport ? runtime.transport.tmuxTarget : undefined;
5546
+ const target = mappedTarget ?? runtimeTarget;
5547
+ if (!target)
5548
+ return false;
5549
+ try {
5550
+ return Boolean(this.tmuxRuntimeManager.getTargetByWindowId(target.sessionName, target.windowId));
5551
+ }
5552
+ catch {
5553
+ return false;
5554
+ }
5555
+ }
5556
+ evictZombieSession(runtime) {
5557
+ const idx = this.sessions.indexOf(runtime);
5558
+ if (idx >= 0) {
5559
+ this.sessions.splice(idx, 1);
5560
+ }
5561
+ this.stoppingSessionIds.delete(runtime.id);
5562
+ this.sessionTmuxTargets.delete(runtime.id);
5563
+ this.writeSessionsFile();
5564
+ this.updateContextWatcherSessions();
5565
+ this.saveState();
5566
+ }
5481
5567
  /** Resume a specific offline session */
5482
5568
  resumeOfflineSession(session) {
5569
+ const existing = this.sessions.find((runtime) => runtime.id === session.id);
5570
+ if (existing) {
5571
+ if (this.isSessionRuntimeLive(existing)) {
5572
+ this.offlineSessions = this.offlineSessions.filter((s) => s.id !== session.id);
5573
+ this.invalidateDesktopStateSnapshot();
5574
+ this.writeStatuslineFile();
5575
+ return;
5576
+ }
5577
+ this.evictZombieSession(existing);
5578
+ }
5483
5579
  const config = loadConfig();
5484
5580
  const toolCfg = config.tools[session.toolConfigKey];
5485
5581
  if (!toolCfg)
5486
5582
  return;
5583
+ const derived = loadMetadataState().sessions[session.id]?.derived;
5584
+ const relaunchFresh = derived?.activity === "error" || derived?.attention === "error";
5585
+ const useBackendResume = !relaunchFresh && this.sessionBootstrap.canResumeWithBackendSessionId(toolCfg, session.backendSessionId);
5487
5586
  let actionArgs;
5488
- if (this.sessionBootstrap.canResumeWithBackendSessionId(toolCfg, session.backendSessionId)) {
5587
+ if (useBackendResume) {
5489
5588
  actionArgs = toolCfg.resumeArgs.map((a) => a.replace("{sessionId}", session.backendSessionId));
5490
5589
  }
5590
+ else if (relaunchFresh) {
5591
+ actionArgs = [];
5592
+ }
5491
5593
  else {
5492
5594
  actionArgs = [...(toolCfg.resumeFallback ?? [])];
5493
5595
  }
5494
- const args = this.sessionBootstrap.composeToolArgs(toolCfg, actionArgs, session.args);
5495
- // Remove from offline list
5596
+ const args = [...(toolCfg.args ?? []), ...actionArgs];
5597
+ if (relaunchFresh) {
5598
+ updateSessionMetadata(session.id, (current) => {
5599
+ const next = { ...current };
5600
+ delete next.derived;
5601
+ delete next.status;
5602
+ delete next.progress;
5603
+ return next;
5604
+ });
5605
+ }
5606
+ const preservedLabel = session.label ?? this.getSessionLabel(session.id);
5496
5607
  this.offlineSessions = this.offlineSessions.filter((s) => s.id !== session.id);
5497
- debug(`resuming offline session ${session.id} (backend=${session.backendSessionId ?? "none"})`, "session");
5608
+ this.invalidateDesktopStateSnapshot();
5609
+ this.saveState();
5610
+ this.writeStatuslineFile();
5611
+ if (preservedLabel) {
5612
+ this.sessionLabels.set(session.id, preservedLabel);
5613
+ }
5614
+ debug(`resuming offline session ${session.id} (${relaunchFresh ? "fresh" : useBackendResume ? `backend=${session.backendSessionId ?? "none"}` : "fallback"})`, "session");
5498
5615
  this.createSession(session.command, args, toolCfg.preambleFlag, session.toolConfigKey, undefined, undefined, // don't pass sessionIdFlag — we're resuming with existing backend ID
5499
- session.worktreePath, session.backendSessionId);
5616
+ session.worktreePath, useBackendResume ? session.backendSessionId : undefined, session.id, true);
5500
5617
  }
5501
5618
  startHeartbeat() {
5502
- if (this.heartbeatInterval)
5503
- return;
5504
- this.heartbeatInterval = setInterval(() => {
5505
- if (this.mode === "project-service") {
5506
- this.syncSessionsFromState();
5507
- return;
5508
- }
5509
- const sessions = this.getInstanceSessionRefs();
5510
- this.instanceDirectory
5511
- .reconcileHeartbeat(this.instanceId, sessions, process.cwd(), this.confirmedRegistered)
5512
- .then((result) => {
5513
- for (const id of result.claimedIds) {
5514
- debug(`session ${id} claimed: was in confirmedRegistered but not in previousIds`, "instance");
5515
- this.handleSessionClaimed(id);
5516
- }
5517
- if (result.skippedClaimDetection && this.confirmedRegistered.size > 0) {
5518
- debug(`skipping claim detection: previousIds empty but ${this.confirmedRegistered.size} confirmed sessions (registry entry may have been pruned)`, "instance");
5519
- }
5520
- this.confirmedRegistered = result.confirmedIds;
5521
- })
5522
- .catch(() => { });
5523
- // Refresh offline sessions from state.json (picks up cross-instance graveyard/kill)
5524
- this.loadOfflineSessions();
5525
- // Refresh dashboard to pick up remote instance changes (skip if overlay is active)
5526
- if (this.mode === "dashboard") {
5527
- this.renderCurrentDashboardView();
5528
- }
5529
- }, 5000);
5619
+ this.runtimeSync.startHeartbeat();
5530
5620
  }
5531
5621
  /**
5532
5622
  * Handle a session that was claimed (taken over) by another aimux instance.
@@ -5555,24 +5645,13 @@ export class Multiplexer {
5555
5645
  this.renderDashboard();
5556
5646
  }
5557
5647
  stopHeartbeat() {
5558
- if (this.heartbeatInterval) {
5559
- clearInterval(this.heartbeatInterval);
5560
- this.heartbeatInterval = null;
5561
- }
5648
+ this.runtimeSync.stopHeartbeat();
5562
5649
  }
5563
5650
  startProjectServiceRefresh() {
5564
- if (this.projectServiceInterval)
5565
- return;
5566
- this.projectServiceInterval = setInterval(() => {
5567
- this.syncSessionsFromState();
5568
- this.writeStatuslineFile();
5569
- }, 2000);
5651
+ this.runtimeSync.startProjectServiceRefresh();
5570
5652
  }
5571
5653
  stopProjectServiceRefresh() {
5572
- if (this.projectServiceInterval) {
5573
- clearInterval(this.projectServiceInterval);
5574
- this.projectServiceInterval = null;
5575
- }
5654
+ this.runtimeSync.stopProjectServiceRefresh();
5576
5655
  }
5577
5656
  getRemoteInstancesSafe() {
5578
5657
  return this.instanceDirectory.getRemoteInstancesSafe(this.instanceId, process.cwd());