aimux-cli 0.1.19 → 0.1.20

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 (86) hide show
  1. package/README.md +13 -4
  2. package/bin/aimux +4 -0
  3. package/bin/aimux-dev +2 -6
  4. package/dist/agent-output-parser-audit.d.ts +23 -0
  5. package/dist/agent-output-parser-audit.js +187 -0
  6. package/dist/agent-output-parser-contract.d.ts +9 -0
  7. package/dist/agent-output-parser-contract.js +33 -0
  8. package/dist/agent-output-parser-fixtures.d.ts +15 -0
  9. package/dist/agent-output-parser-fixtures.js +593 -0
  10. package/dist/agent-output-parser-harness.d.ts +21 -0
  11. package/dist/agent-output-parser-harness.js +43 -0
  12. package/dist/agent-output-parser-test-utils.d.ts +1 -0
  13. package/dist/agent-output-parser-test-utils.js +7 -0
  14. package/dist/agent-output-parser.js +215 -35
  15. package/dist/atomic-write.d.ts +15 -0
  16. package/dist/atomic-write.js +69 -4
  17. package/dist/attachment-store.d.ts +7 -0
  18. package/dist/attachment-store.js +64 -5
  19. package/dist/backend-session-discovery.d.ts +17 -0
  20. package/dist/backend-session-discovery.js +57 -0
  21. package/dist/config.js +9 -4
  22. package/dist/connection-targets.js +20 -1
  23. package/dist/context/context-bridge.js +4 -1
  24. package/dist/credentials.js +3 -6
  25. package/dist/daemon.d.ts +1 -0
  26. package/dist/daemon.js +16 -0
  27. package/dist/dashboard/index.d.ts +1 -0
  28. package/dist/dashboard/index.js +1 -0
  29. package/dist/dashboard/targets.js +14 -2
  30. package/dist/dashboard/ui-state-store.js +4 -3
  31. package/dist/last-used.js +3 -2
  32. package/dist/launcher-env.d.ts +4 -0
  33. package/dist/launcher-env.js +70 -0
  34. package/dist/main.js +16 -1
  35. package/dist/metadata-server.d.ts +13 -2
  36. package/dist/metadata-server.js +60 -4
  37. package/dist/metadata-store.js +4 -3
  38. package/dist/mobile-push-bridge.d.ts +8 -0
  39. package/dist/mobile-push-bridge.js +22 -0
  40. package/dist/mobile-push-throttle.d.ts +23 -0
  41. package/dist/mobile-push-throttle.js +53 -0
  42. package/dist/multiplexer/dashboard-model.js +3 -2
  43. package/dist/multiplexer/dashboard-ops.d.ts +3 -2
  44. package/dist/multiplexer/dashboard-ops.js +2 -2
  45. package/dist/multiplexer/dashboard-tail-methods.d.ts +3 -2
  46. package/dist/multiplexer/dashboard-tail-methods.js +2 -2
  47. package/dist/multiplexer/dashboard-view-methods.js +2 -0
  48. package/dist/multiplexer/index.d.ts +1 -1
  49. package/dist/multiplexer/index.js +4 -4
  50. package/dist/multiplexer/persistence-methods.js +2 -1
  51. package/dist/multiplexer/runtime-lifecycle-methods.js +6 -2
  52. package/dist/multiplexer/runtime-state.js +13 -1
  53. package/dist/multiplexer/service-state-snapshot.js +4 -2
  54. package/dist/multiplexer/services.js +5 -4
  55. package/dist/multiplexer/session-launch.d.ts +1 -1
  56. package/dist/multiplexer/session-launch.js +18 -6
  57. package/dist/multiplexer/session-runtime-core.js +9 -2
  58. package/dist/multiplexer/tool-picker.d.ts +2 -1
  59. package/dist/multiplexer/tool-picker.js +29 -21
  60. package/dist/notify.d.ts +1 -1
  61. package/dist/notify.js +8 -5
  62. package/dist/paths.js +50 -4
  63. package/dist/project-takeover.d.ts +1 -0
  64. package/dist/project-takeover.js +117 -0
  65. package/dist/relay-client.d.ts +10 -0
  66. package/dist/relay-client.js +5 -0
  67. package/dist/runtime-core/backend-id-reconcile.d.ts +13 -0
  68. package/dist/runtime-core/backend-id-reconcile.js +23 -0
  69. package/dist/runtime-core/exchange-store.js +3 -8
  70. package/dist/runtime-core/topology-store.js +3 -8
  71. package/dist/runtime-owner.d.ts +3 -0
  72. package/dist/runtime-owner.js +10 -0
  73. package/dist/shell-args.d.ts +13 -0
  74. package/dist/shell-args.js +25 -0
  75. package/dist/shell-hooks.d.ts +1 -0
  76. package/dist/shell-hooks.js +1 -0
  77. package/dist/team.js +4 -3
  78. package/dist/tmux/runtime-manager.js +2 -0
  79. package/dist/tui/screens/dashboard-renderers.js +6 -6
  80. package/dist/vitest.setup.d.ts +1 -0
  81. package/dist/vitest.setup.js +9 -0
  82. package/dist-ui/_expo/static/css/web-8782287775683e5a944b821b854d0f60.css +1 -0
  83. package/dist-ui/_expo/static/js/web/{entry-477c745b2adc79367a4380ecf07d9ff6.js → entry-90d00d223eefabe5cc21e4329b274fa5.js} +260 -252
  84. package/dist-ui/index.html +2 -2
  85. package/package.json +3 -1
  86. package/dist-ui/_expo/static/css/web-30453ede1678c16acb08b97e83e8646d.css +0 -1
@@ -0,0 +1,53 @@
1
+ const DEDUPE_TTL_MS = 60_000;
2
+ const SESSION_WINDOW_MS = 60_000;
3
+ const SESSION_LIMIT = 5;
4
+ /**
5
+ * In-memory guard for outbound mobile pushes. Collapses identical alerts
6
+ * re-emitted within a TTL window (chatty idle/needs_input polling) and caps the
7
+ * push rate per session so one runaway agent cannot flood the device.
8
+ */
9
+ export class MobilePushThrottle {
10
+ dedupeTtlMs;
11
+ sessionLimit;
12
+ sessionWindowMs;
13
+ now;
14
+ lastByKey = new Map();
15
+ sessionHits = new Map();
16
+ constructor(dedupeTtlMs = DEDUPE_TTL_MS, sessionLimit = SESSION_LIMIT, sessionWindowMs = SESSION_WINDOW_MS, now = () => Date.now()) {
17
+ this.dedupeTtlMs = dedupeTtlMs;
18
+ this.sessionLimit = sessionLimit;
19
+ this.sessionWindowMs = sessionWindowMs;
20
+ this.now = now;
21
+ }
22
+ allow(input) {
23
+ const ts = this.now();
24
+ this.prune(ts);
25
+ const key = input.dedupeKey?.trim() || [input.sessionId, input.kind, input.title, input.body].map((p) => p ?? "").join("|");
26
+ const last = this.lastByKey.get(key);
27
+ if (last !== undefined && ts - last < this.dedupeTtlMs)
28
+ return false;
29
+ const session = input.sessionId?.trim() || "_global";
30
+ const hits = (this.sessionHits.get(session) ?? []).filter((t) => ts - t < this.sessionWindowMs);
31
+ if (hits.length >= this.sessionLimit) {
32
+ this.sessionHits.set(session, hits);
33
+ return false;
34
+ }
35
+ hits.push(ts);
36
+ this.sessionHits.set(session, hits);
37
+ this.lastByKey.set(key, ts);
38
+ return true;
39
+ }
40
+ prune(ts) {
41
+ for (const [key, t] of this.lastByKey) {
42
+ if (ts - t >= this.dedupeTtlMs)
43
+ this.lastByKey.delete(key);
44
+ }
45
+ for (const [session, hits] of this.sessionHits) {
46
+ const fresh = hits.filter((t) => ts - t < this.sessionWindowMs);
47
+ if (fresh.length === 0)
48
+ this.sessionHits.delete(session);
49
+ else
50
+ this.sessionHits.set(session, fresh);
51
+ }
52
+ }
53
+ }
@@ -819,7 +819,7 @@ export async function startProjectServices(host) {
819
819
  targetSessionId: input.sessionId,
820
820
  targetWorktreePath: input.worktreePath,
821
821
  open: input.open ?? false,
822
- extraArgs: input.extraArgs,
822
+ launchOverride: input.launchOverride,
823
823
  }), input.sessionId
824
824
  ? buildMetadataPendingSessionSeed({
825
825
  sessionId: input.sessionId,
@@ -862,7 +862,7 @@ export async function startProjectServices(host) {
862
862
  instruction: input.instruction,
863
863
  targetWorktreePath: input.worktreePath,
864
864
  open: input.open ?? false,
865
- extraArgs: input.extraArgs,
865
+ launchOverride: input.launchOverride,
866
866
  }), input.targetSessionId
867
867
  ? buildMetadataPendingSessionSeed({
868
868
  sessionId: input.targetSessionId,
@@ -876,6 +876,7 @@ export async function startProjectServices(host) {
876
876
  renameAgent: (input) => host.renameAgent(input.sessionId, input.label),
877
877
  migrateAgent: (input) => withMetadataSessionPending(host, input.sessionId, "migrating", () => host.migrateAgent(input.sessionId, input.worktreePath), findDashboardSessionSeed(host, input.sessionId)),
878
878
  killAgent: (input) => withMetadataSessionPending(host, input.sessionId, "graveyarding", () => host.sendAgentToGraveyard(input.sessionId), findDashboardSessionSeed(host, input.sessionId)),
879
+ recordBackendSessionId: (input) => host.recordSessionBackendSessionId(input.sessionId, input.backendSessionId),
879
880
  sendAgentInput: (input) => host.sendAgentInput(input.sessionId, input.text),
880
881
  readAgentOutput: (input) => host.readAgentOutput(input.sessionId, input.startLine),
881
882
  },
@@ -1,12 +1,13 @@
1
1
  import type { DashboardService, DashboardSession } from "../dashboard/index.js";
2
2
  import type { PendingServiceActionKind, PendingSessionActionKind } from "../pending-actions.js";
3
+ import type { LaunchOverride } from "../shell-args.js";
3
4
  type DashboardOpsHost = any;
4
5
  export declare function runDashboardOperation<T>(host: DashboardOpsHost, title: string, lines: string[], work: () => Promise<T> | T, errorTitle?: string): Promise<T | undefined>;
5
6
  export declare function spawnDashboardAgentWithFeedback(host: DashboardOpsHost, input: {
6
7
  sessionId: string;
7
8
  tool: string;
8
9
  worktreePath?: string;
9
- extraArgs?: string[];
10
+ launchOverride?: LaunchOverride;
10
11
  }): Promise<void>;
11
12
  export declare function forkDashboardAgentWithFeedback(host: DashboardOpsHost, input: {
12
13
  sourceSessionId: string;
@@ -14,7 +15,7 @@ export declare function forkDashboardAgentWithFeedback(host: DashboardOpsHost, i
14
15
  tool: string;
15
16
  instruction?: string;
16
17
  worktreePath?: string;
17
- extraArgs?: string[];
18
+ launchOverride?: LaunchOverride;
18
19
  }): Promise<void>;
19
20
  export declare function setPendingDashboardSessionAction(host: DashboardOpsHost, sessionId: string, kind: PendingSessionActionKind | null, opts?: {
20
21
  sessionSeed?: DashboardSession;
@@ -300,7 +300,7 @@ export async function spawnDashboardAgentWithFeedback(host, input) {
300
300
  tool: input.tool,
301
301
  sessionId: input.sessionId,
302
302
  worktreePath: input.worktreePath,
303
- extraArgs: input.extraArgs,
303
+ launchOverride: input.launchOverride,
304
304
  open: false,
305
305
  }, { timeoutMs: 10_000 });
306
306
  },
@@ -330,7 +330,7 @@ export async function forkDashboardAgentWithFeedback(host, input) {
330
330
  tool: input.tool,
331
331
  instruction: input.instruction,
332
332
  worktreePath: input.worktreePath,
333
- extraArgs: input.extraArgs,
333
+ launchOverride: input.launchOverride,
334
334
  open: false,
335
335
  }, { timeoutMs: 10_000 });
336
336
  },
@@ -3,6 +3,7 @@ import type { Multiplexer, SessionState } from "./index.js";
3
3
  import { dashboardSessionActionDeps as dashboardSessionActionDepsImpl } from "./dashboard-ops.js";
4
4
  import type { PendingServiceActionKind, PendingSessionActionKind } from "../pending-actions.js";
5
5
  import type { SessionRuntime } from "../session-runtime.js";
6
+ import type { LaunchOverride } from "../shell-args.js";
6
7
  export type DashboardTailMethods = {
7
8
  forkAgent(this: Multiplexer, opts: {
8
9
  sourceSessionId: string;
@@ -11,7 +12,7 @@ export type DashboardTailMethods = {
11
12
  instruction?: string;
12
13
  targetWorktreePath?: string;
13
14
  open?: boolean;
14
- extraArgs?: string[];
15
+ launchOverride?: LaunchOverride;
15
16
  }): Promise<{
16
17
  sessionId: string;
17
18
  threadId: string;
@@ -21,7 +22,7 @@ export type DashboardTailMethods = {
21
22
  targetSessionId?: string;
22
23
  targetWorktreePath?: string;
23
24
  open?: boolean;
24
- extraArgs?: string[];
25
+ launchOverride?: LaunchOverride;
25
26
  }): Promise<{
26
27
  sessionId: string;
27
28
  }>;
@@ -55,7 +55,7 @@ function refreshLifecycleViews(host) {
55
55
  }
56
56
  export const dashboardTailMethods = {
57
57
  async forkAgent(opts) {
58
- const result = await this.forkSessionFromSource(opts.sourceSessionId, opts.targetToolConfigKey, opts.targetSessionId, opts.instruction, opts.targetWorktreePath, opts.extraArgs ?? []);
58
+ const result = await this.forkSessionFromSource(opts.sourceSessionId, opts.targetToolConfigKey, opts.targetSessionId, opts.instruction, opts.targetWorktreePath, opts.launchOverride);
59
59
  if (!result) {
60
60
  throw new Error(`Unable to fork agent "${opts.sourceSessionId}"`);
61
61
  }
@@ -71,7 +71,7 @@ export const dashboardTailMethods = {
71
71
  throw new Error(`Unknown tool config: ${opts.toolConfigKey}`);
72
72
  }
73
73
  const sessionId = opts.targetSessionId ?? this.generateDashboardSessionId?.(tool.command);
74
- const transport = this.createSession(tool.command, [...tool.args, ...(opts.extraArgs ?? [])], tool.preambleFlag, opts.toolConfigKey, undefined, tool.sessionIdFlag, opts.targetWorktreePath, undefined, sessionId, !opts.open);
74
+ const transport = this.createSession(opts.launchOverride?.command ?? tool.command, opts.launchOverride?.args ?? tool.args, tool.preambleFlag, opts.toolConfigKey, undefined, tool.sessionIdFlag, opts.targetWorktreePath, undefined, sessionId, !opts.open, false, undefined, opts.launchOverride?.env);
75
75
  if (opts.open) {
76
76
  this.openLiveTmuxWindowForEntry({ id: transport.id });
77
77
  }
@@ -2,6 +2,7 @@ import { closeNotificationPanel as closeNotificationPanelImpl, handleNotificatio
2
2
  import { beginWorktreeRemoval as beginWorktreeRemovalImpl, finishWorktreeRemoval as finishWorktreeRemovalImpl, handleWorktreeInputKey as handleWorktreeInputKeyImpl, handleWorktreeRemoveConfirmKey as handleWorktreeRemoveConfirmKeyImpl, handleWorktreeListKey as handleWorktreeListKeyImpl, renderWorktreeInput as renderWorktreeInputImpl, renderWorktreeList as renderWorktreeListImpl, renderWorktreeRemoveConfirm as renderWorktreeRemoveConfirmImpl, showWorktreeCreatePrompt as showWorktreeCreatePromptImpl, showWorktreeList as showWorktreeListImpl, } from "./worktrees.js";
3
3
  import { createService as createServiceImpl, removeOfflineService as removeOfflineServiceImpl, resumeOfflineService as resumeOfflineServiceImpl, resumeOfflineServiceById as resumeOfflineServiceByIdImpl, serviceLabelForCommand as serviceLabelForCommandImpl, stopService as stopServiceImpl, } from "./services.js";
4
4
  import { derivedStatusLabel } from "../dashboard/index.js";
5
+ import { isDevelopmentRuntime } from "../connection-targets.js";
5
6
  import { selectDashboardTeammates } from "../dashboard/session-registry.js";
6
7
  import { hasRuntimeEvidence, isAttachableDashboardSessionEntry } from "../dashboard/runtime-evidence.js";
7
8
  export const dashboardViewMethods = {
@@ -113,6 +114,7 @@ export const dashboardViewMethods = {
113
114
  selectedServiceId: selectedService,
114
115
  selectedTeammates: selectDashboardTeammates(dashTeammates, selectedSessionEntry),
115
116
  runtimeLabel: "tmux",
117
+ isDevRuntime: isDevelopmentRuntime(),
116
118
  mainCheckout: mainCheckoutInfo,
117
119
  operationFailures: this.dashboardOperationFailuresCache ?? [],
118
120
  worktreeRemoval: this.worktreeRemovalJob
@@ -185,7 +185,7 @@ export declare class Multiplexer {
185
185
  * Starts fresh sessions but with context from the previous conversation.
186
186
  */
187
187
  restoreSessions(toolFilter?: string): Promise<number>;
188
- createSession(command: string, args: string[], preambleFlag?: string[], toolConfigKey?: string, extraPreamble?: string, sessionIdFlag?: string[], worktreePath?: string, backendSessionIdOverride?: string, sessionIdOverride?: string, detachedInTmux?: boolean, suppressStartupPreamble?: boolean, team?: SessionTeamMetadata): SessionTransport;
188
+ createSession(command: string, args: string[], preambleFlag?: string[], toolConfigKey?: string, extraPreamble?: string, sessionIdFlag?: string[], worktreePath?: string, backendSessionIdOverride?: string, sessionIdOverride?: string, detachedInTmux?: boolean, suppressStartupPreamble?: boolean, team?: SessionTeamMetadata, launchEnv?: Record<string, string>): SessionTransport;
189
189
  recordSessionBackendSessionId(sessionId: string, backendSessionId: string): {
190
190
  sessionId: string;
191
191
  backendSessionId: string;
@@ -317,8 +317,8 @@ export class Multiplexer {
317
317
  async restoreSessions(toolFilter) {
318
318
  return restoreSessionsImpl(this, toolFilter);
319
319
  }
320
- createSession(command, args, preambleFlag, toolConfigKey, extraPreamble, sessionIdFlag, worktreePath, backendSessionIdOverride, sessionIdOverride, detachedInTmux = false, suppressStartupPreamble = false, team) {
321
- return createSessionImpl(this, command, args, preambleFlag, toolConfigKey, extraPreamble, sessionIdFlag, worktreePath, backendSessionIdOverride, sessionIdOverride, detachedInTmux, suppressStartupPreamble, team);
320
+ createSession(command, args, preambleFlag, toolConfigKey, extraPreamble, sessionIdFlag, worktreePath, backendSessionIdOverride, sessionIdOverride, detachedInTmux = false, suppressStartupPreamble = false, team, launchEnv) {
321
+ return createSessionImpl(this, command, args, preambleFlag, toolConfigKey, extraPreamble, sessionIdFlag, worktreePath, backendSessionIdOverride, sessionIdOverride, detachedInTmux, suppressStartupPreamble, team, launchEnv);
322
322
  }
323
323
  recordSessionBackendSessionId(sessionId, backendSessionId) {
324
324
  return runtimeLifecycleMethods.recordSessionBackendSessionId.call(this, sessionId, backendSessionId);
@@ -348,7 +348,7 @@ export class Multiplexer {
348
348
  handleAction(action) {
349
349
  handleActionImpl(this, action);
350
350
  }
351
- async forkSessionFromSource(sourceSessionId, targetToolConfigKey, requestedTargetSessionId, instruction, targetWorktreePath, extraArgs = []) {
351
+ async forkSessionFromSource(sourceSessionId, targetToolConfigKey, requestedTargetSessionId, instruction, targetWorktreePath, launchOverride) {
352
352
  const sourceSession = this.sessions.find((session) => session.id === sourceSessionId);
353
353
  if (!sourceSession) {
354
354
  this.showDashboardError("Cannot fork missing session", [`Source session ${sourceSessionId} not found.`]);
@@ -402,7 +402,7 @@ export class Multiplexer {
402
402
  ]
403
403
  .filter(Boolean)
404
404
  .join("\n\n");
405
- const transport = this.createSession(toolCfg.command, [...toolCfg.args, ...extraArgs], toolCfg.preambleFlag, targetToolConfigKey, extraPreamble, toolCfg.sessionIdFlag, targetWorktree, undefined, targetSessionId, !toolCfg.preambleFlag);
405
+ const transport = this.createSession(launchOverride?.command ?? toolCfg.command, launchOverride?.args ?? toolCfg.args, toolCfg.preambleFlag, targetToolConfigKey, extraPreamble, toolCfg.sessionIdFlag, targetWorktree, undefined, targetSessionId, !toolCfg.preambleFlag, false, undefined, launchOverride?.env);
406
406
  this.agentTracker.emit(sourceSessionId, {
407
407
  kind: "status",
408
408
  message: `Forked ${targetSessionId} from this session`,
@@ -5,6 +5,7 @@ import { addDashboardOperationFailure, clearDashboardOperationFailures, listDash
5
5
  import { composeDashboardWorktreeGroups } from "./dashboard-model.js";
6
6
  import { loadDaemonInfo } from "../daemon.js";
7
7
  import { getProjectStateDir, getStatePath } from "../paths.js";
8
+ import { writeJsonAtomic } from "../atomic-write.js";
8
9
  import { loadMetadataState } from "../metadata-store.js";
9
10
  import { createRuntimeExchangeStore } from "../runtime-core/exchange-store.js";
10
11
  import { renderCurrentDashboardView as renderCurrentDashboardViewImpl } from "./runtime-state.js";
@@ -855,7 +856,7 @@ function removePersistedServicesForWorktree(path) {
855
856
  const nextServices = (state.services ?? []).filter((service) => service.worktreePath !== path);
856
857
  if (nextServices.length === (state.services ?? []).length)
857
858
  return;
858
- writeFileSync(statePath, JSON.stringify({ ...state, services: nextServices }, null, 2) + "\n");
859
+ writeJsonAtomic(statePath, { ...state, services: nextServices });
859
860
  }
860
861
  catch { }
861
862
  }
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import { closeDebug, debug } from "../debug.js";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { getStatePath } from "../paths.js";
7
+ import { quarantineCorruptFile, writeJsonAtomic } from "../atomic-write.js";
7
8
  import { buildAimuxAgentInstructions } from "../session-bootstrap.js";
8
9
  import { listTopologySessionStates, saveRuntimeTopologySessions } from "../runtime-core/topology-sessions.js";
9
10
  import { adjustAfterRemove as adjustAfterRemoveImpl, buildLiveServiceStates as buildLiveServiceStatesImpl, evictZombieSession as evictZombieSessionImpl, graveyardSession as graveyardSessionImpl, isSessionRuntimeLive as isSessionRuntimeLiveImpl, loadOfflineServices as loadOfflineServicesImpl, loadOfflineTopologySessions as loadOfflineTopologySessionsImpl, restoreTmuxSessionsFromTopology as restoreTmuxSessionsFromTopologyImpl, recordSessionBackendSessionId as recordSessionBackendSessionIdImpl, resumeOfflineSession as resumeOfflineSessionImpl, startHeartbeat as startHeartbeatImpl, startProjectServiceRefresh as startProjectServiceRefreshImpl, startStatusRefresh as startStatusRefreshImpl, stopHeartbeat as stopHeartbeatImpl, stopProjectServiceRefresh as stopProjectServiceRefreshImpl, stopSessionToOffline as stopSessionToOfflineImpl, stopStatusRefresh as stopStatusRefreshImpl, syncSessionsFromTopology as syncSessionsFromTopologyImpl, } from "./runtime-state.js";
@@ -92,6 +93,7 @@ export function loadStateStatic() {
92
93
  };
93
94
  }
94
95
  catch {
96
+ quarantineCorruptFile(statePath);
95
97
  return null;
96
98
  }
97
99
  }
@@ -261,7 +263,9 @@ export const runtimeLifecycleMethods = {
261
263
  });
262
264
  mergedServices = [...otherServices, ...myServices];
263
265
  }
264
- catch { }
266
+ catch {
267
+ quarantineCorruptFile(statePath);
268
+ }
265
269
  }
266
270
  saveRuntimeTopologySessions({ sessions: mergedSessions });
267
271
  unpreservedExitedIds.clear();
@@ -270,7 +274,7 @@ export const runtimeLifecycleMethods = {
270
274
  cwd: process.cwd(),
271
275
  services: mergedServices,
272
276
  };
273
- writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
277
+ writeJsonAtomic(statePath, state);
274
278
  this.invalidateDesktopStateSnapshot();
275
279
  },
276
280
  teardown() {
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { discoverBackendSessionId } from "../backend-session-discovery.js";
2
3
  import { loadConfig } from "../config.js";
3
4
  import { loadMetadataState, updateSessionMetadata } from "../metadata-store.js";
4
5
  import { isToolInternalWorktree, listWorktrees as listAllWorktrees } from "../worktree.js";
@@ -419,7 +420,18 @@ export function resumeOfflineSession(host, session) {
419
420
  return;
420
421
  const derived = loadMetadataState().sessions[session.id]?.derived;
421
422
  const relaunchFresh = derived?.activity === "error" || derived?.attention === "error";
422
- const backendSessionId = session.backendSessionId;
423
+ let backendSessionId = session.backendSessionId;
424
+ if (!backendSessionId && !relaunchFresh) {
425
+ // The durable backend id can be lost if a crash killed the tmux pane before
426
+ // it was captured. Recover it from the tool's on-disk session store so the
427
+ // agent stays resumable instead of being stranded.
428
+ const discovered = discoverBackendSessionId(session.toolConfigKey, session.worktreePath);
429
+ if (discovered) {
430
+ backendSessionId = discovered;
431
+ session.backendSessionId = discovered;
432
+ host.debug?.(`reconciled backend session id for ${session.id} from disk: ${discovered}`, "session");
433
+ }
434
+ }
423
435
  const useBackendResume = !relaunchFresh && host.sessionBootstrap.canResumeWithBackendSessionId(toolCfg, backendSessionId);
424
436
  let actionArgs;
425
437
  if (useBackendResume) {
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { getStatePath } from "../paths.js";
3
+ import { quarantineCorruptFile, writeJsonAtomic } from "../atomic-write.js";
3
4
  import { buildServiceStateFromMetadata } from "./services.js";
4
5
  import { listWorktreeGraveyardPaths } from "./worktree-graveyard.js";
5
6
  function isAvailableSnapshotWorktree(worktreePath, graveyardPaths = listWorktreeGraveyardPaths()) {
@@ -61,11 +62,12 @@ export function persistProjectRuntimeSnapshotsBeforeTmuxStop(projectRoot, tmux)
61
62
  existing = JSON.parse(readFileSync(statePath, "utf-8"));
62
63
  }
63
64
  catch {
65
+ quarantineCorruptFile(statePath);
64
66
  existing = null;
65
67
  }
66
68
  }
67
69
  const nextState = mergeRuntimeSnapshots(existing, { services }, projectRoot);
68
- writeFileSync(statePath, JSON.stringify(nextState, null, 2) + "\n");
70
+ writeJsonAtomic(statePath, nextState);
69
71
  return { sessions: [], services };
70
72
  }
71
73
  export function persistProjectServiceSnapshotsBeforeRuntimeStop(projectRoot, tmux) {
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { findMainRepo } from "../worktree.js";
4
4
  import { getStatePath } from "../paths.js";
5
+ import { writeJsonAtomic } from "../atomic-write.js";
5
6
  import { wrapCommandWithShellIntegration, wrapInteractiveShellWithIntegration } from "../shell-hooks.js";
6
7
  import { markLastUsed } from "../last-used.js";
7
8
  import { removeTopologyService, upsertTopologyService, } from "../runtime-core/topology-services.js";
@@ -119,7 +120,7 @@ export function createService(host, commandLine, worktreePath, opts) {
119
120
  const command = wrapped.command;
120
121
  const args = wrapped.args;
121
122
  const label = serviceLabelForCommand(trimmed);
122
- const tmuxSession = host.tmuxRuntimeManager.ensureProjectSession(process.cwd());
123
+ const tmuxSession = host.tmuxRuntimeManager.ensureProjectSession(projectRoot);
123
124
  const shouldRenderPending = host.startedInDashboard && host.mode === "dashboard";
124
125
  if (shouldRenderPending) {
125
126
  host.setPendingDashboardServiceAction(serviceId, "creating", {
@@ -237,7 +238,7 @@ export function removeOfflineService(host, serviceId) {
237
238
  try {
238
239
  const state = JSON.parse(readFileSync(statePath, "utf-8"));
239
240
  state.services = (state.services ?? []).filter((service) => service.id !== serviceId);
240
- writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
241
+ writeJsonAtomic(statePath, state);
241
242
  }
242
243
  catch { }
243
244
  }
@@ -299,7 +300,7 @@ export function resumeOfflineService(host, service) {
299
300
  const command = wrapped.command;
300
301
  const args = wrapped.args;
301
302
  const label = service.label ?? serviceLabelForCommand(launchCommandLine);
302
- const tmuxSession = host.tmuxRuntimeManager.ensureProjectSession(process.cwd());
303
+ const tmuxSession = host.tmuxRuntimeManager.ensureProjectSession(projectRoot);
303
304
  const retainedTarget = service.tmuxTarget && host.tmuxRuntimeManager.hasWindow?.(service.tmuxTarget) ? service.tmuxTarget : undefined;
304
305
  const target = retainedTarget ??
305
306
  host.tmuxRuntimeManager.createWindow(tmuxSession.sessionName, label, resumeCwd, command, args, {
@@ -10,7 +10,7 @@ export declare function runDashboard(host: SessionLaunchHost): Promise<number>;
10
10
  export declare function runProjectService(host: SessionLaunchHost): Promise<number>;
11
11
  export declare function resumeSessions(host: SessionLaunchHost, toolFilter?: string): Promise<number>;
12
12
  export declare function restoreSessions(host: SessionLaunchHost, toolFilter?: string): Promise<number>;
13
- export declare function createSession(host: SessionLaunchHost, command: string, args: string[], preambleFlag?: string[], toolConfigKey?: string, extraPreamble?: string, sessionIdFlag?: string[], worktreePath?: string, backendSessionIdOverride?: string, sessionIdOverride?: string, detachedInTmux?: boolean, suppressStartupPreamble?: boolean, team?: SessionTeamMetadata): any;
13
+ export declare function createSession(host: SessionLaunchHost, command: string, args: string[], preambleFlag?: string[], toolConfigKey?: string, extraPreamble?: string, sessionIdFlag?: string[], worktreePath?: string, backendSessionIdOverride?: string, sessionIdOverride?: string, detachedInTmux?: boolean, suppressStartupPreamble?: boolean, team?: SessionTeamMetadata, launchEnv?: Record<string, string>): any;
14
14
  export declare function migrateAgent(host: SessionLaunchHost, sessionId: string, targetWorktreePath: string): Promise<void>;
15
15
  export declare function getSessionWorktreePath(host: SessionLaunchHost, sessionId: string): string | undefined;
16
16
  export declare function getSessionsByWorktree(host: SessionLaunchHost): Map<string | undefined, any[]>;
@@ -306,7 +306,7 @@ export async function restoreSessions(host, toolFilter) {
306
306
  host.openTmuxDashboardTarget();
307
307
  return 0;
308
308
  }
309
- export function createSession(host, command, args, preambleFlag, toolConfigKey, extraPreamble, sessionIdFlag, worktreePath, backendSessionIdOverride, sessionIdOverride, detachedInTmux = false, suppressStartupPreamble = false, team) {
309
+ export function createSession(host, command, args, preambleFlag, toolConfigKey, extraPreamble, sessionIdFlag, worktreePath, backendSessionIdOverride, sessionIdOverride, detachedInTmux = false, suppressStartupPreamble = false, team, launchEnv) {
310
310
  const cols = process.stdout.columns ?? 80;
311
311
  const sessionId = sessionIdOverride ?? `${command}-${Math.random().toString(36).slice(2, 8)}`;
312
312
  if (host.sessions.some((session) => session.id === sessionId)) {
@@ -314,6 +314,8 @@ export function createSession(host, command, args, preambleFlag, toolConfigKey,
314
314
  }
315
315
  const config = loadConfig();
316
316
  const toolCfg = toolConfigKey ? config.tools[toolConfigKey] : undefined;
317
+ // A launch override may swap the binary; aimux flags/preamble only apply to the tool's own command.
318
+ const isConfiguredToolCommand = Boolean(toolCfg && toolCfg.command === command);
317
319
  const isClaudeResumeStyleLaunch = Boolean(toolCfg && toolConfigKey === "claude" && toolCfg.command === command) &&
318
320
  shouldSkipClaudeSessionIdInjection(args);
319
321
  const explicitClaudeBackendSessionId = toolCfg && toolConfigKey === "claude" && toolCfg.command === command
@@ -323,7 +325,7 @@ export function createSession(host, command, args, preambleFlag, toolConfigKey,
323
325
  ? extractCodexBackendSessionIdFromArgs(args)
324
326
  : undefined;
325
327
  const effectiveSuppressStartupPreamble = suppressStartupPreamble;
326
- const effectiveSessionIdFlag = isClaudeResumeStyleLaunch ? undefined : sessionIdFlag;
328
+ const effectiveSessionIdFlag = isConfiguredToolCommand && !isClaudeResumeStyleLaunch ? sessionIdFlag : undefined;
327
329
  const backendSessionId = backendSessionIdOverride ??
328
330
  explicitClaudeBackendSessionId ??
329
331
  explicitCodexBackendSessionId ??
@@ -339,7 +341,7 @@ export function createSession(host, command, args, preambleFlag, toolConfigKey,
339
341
  includeAimuxPreamble: automaticPreambleEnabled,
340
342
  team,
341
343
  });
342
- const shouldInjectLaunchPreamble = Boolean(!effectiveSuppressStartupPreamble && preambleFlag && preamble.trim());
344
+ const shouldInjectLaunchPreamble = Boolean(isConfiguredToolCommand && !effectiveSuppressStartupPreamble && preambleFlag && preamble.trim());
343
345
  const shouldInjectCodexDeveloperInstructions = Boolean(!effectiveSuppressStartupPreamble &&
344
346
  toolCfg?.command === command &&
345
347
  command === "codex" &&
@@ -375,6 +377,7 @@ export function createSession(host, command, args, preambleFlag, toolConfigKey,
375
377
  command: launchCommand,
376
378
  args: finalArgs,
377
379
  extraEnv: {
380
+ ...(launchEnv ?? {}),
378
381
  AIMUX_SESSION_ID: sessionId,
379
382
  AIMUX_TOOL: toolConfigKey ?? command,
380
383
  },
@@ -382,13 +385,23 @@ export function createSession(host, command, args, preambleFlag, toolConfigKey,
382
385
  launchCommand = wrapped.command;
383
386
  finalArgs = wrapped.args;
384
387
  }
385
- else if (toolCfg && toolCfg.command === command) {
388
+ else if (isConfiguredToolCommand) {
386
389
  const wrapped = wrapCommandWithShellIntegration({
387
390
  projectRoot,
388
391
  sessionId,
389
392
  tool: toolConfigKey ?? command,
390
393
  command: launchCommand,
391
394
  args: finalArgs,
395
+ extraEnv: launchEnv,
396
+ });
397
+ launchCommand = wrapped.command;
398
+ finalArgs = wrapped.args;
399
+ }
400
+ else if (launchEnv && Object.keys(launchEnv).length > 0) {
401
+ const wrapped = wrapCommandWithManagedLaunchEnv({
402
+ command: launchCommand,
403
+ args: finalArgs,
404
+ extraEnv: launchEnv,
392
405
  });
393
406
  launchCommand = wrapped.command;
394
407
  finalArgs = wrapped.args;
@@ -399,7 +412,7 @@ export function createSession(host, command, args, preambleFlag, toolConfigKey,
399
412
  debug(`creating session: ${command} (configKey=${toolConfigKey ?? "cli"}, backendId=${backendSessionId ?? "none"}, cwd=${worktreePath ?? process.cwd()}, args=${finalArgs.length})`, "session");
400
413
  debug(`spawn args: ${JSON.stringify(summarizeLaunchArgs(finalArgs))}`, "session");
401
414
  const sessionStartTime = Date.now();
402
- const tmuxSession = host.tmuxRuntimeManager.ensureProjectSession(process.cwd());
415
+ const tmuxSession = host.tmuxRuntimeManager.ensureProjectSession(projectRoot);
403
416
  const target = host.tmuxRuntimeManager.createWindow(tmuxSession.sessionName, host.getSessionLabel(sessionId) ?? command, worktreePath ?? process.cwd(), launchCommand, finalArgs, { detached: detachedInTmux });
404
417
  const tmuxTransport = new TmuxSessionTransport(sessionId, command, target, host.tmuxRuntimeManager, cols, process.stdout.rows ?? 24);
405
418
  host.sessionTmuxTargets.set(sessionId, target);
@@ -409,7 +422,6 @@ export function createSession(host, command, args, preambleFlag, toolConfigKey,
409
422
  if (session instanceof TmuxSessionTransport) {
410
423
  host.syncTmuxWindowMetadata(sessionId);
411
424
  }
412
- void projectRoot;
413
425
  host.activeIndex = host.sessions.length - 1;
414
426
  if (host.startedInDashboard && host.mode === "dashboard") {
415
427
  host.invalidateDesktopStateSnapshot();
@@ -8,6 +8,7 @@ import { SessionRuntime } from "../session-runtime.js";
8
8
  import { TmuxSessionTransport } from "../tmux/session-transport.js";
9
9
  import { loadMetadataState } from "../metadata-store.js";
10
10
  import { parseAgentOutput } from "../agent-output-parser.js";
11
+ import { normalizeSubmittedPrompt, waitForTmuxPromptSubmit } from "../agent-prompt-delivery.js";
11
12
  import { captureGitContext } from "../context/context-bridge.js";
12
13
  export function getSessionLabel(host, sessionId) {
13
14
  return (host.sessionLabels.get(sessionId) ?? host.offlineSessions.find((session) => session.id === sessionId)?.label);
@@ -166,8 +167,14 @@ export async function sendAgentInput(host, sessionId, text) {
166
167
  if (!target)
167
168
  throw new Error(`Session "${sessionId}" does not have a live tmux target`);
168
169
  session.transport.retarget(target);
169
- session.transport.write(text);
170
- session.transport.write("\r");
170
+ const prompt = normalizeSubmittedPrompt(host.sessionToolKeys.get(sessionId), text, true);
171
+ session.transport.write(prompt);
172
+ await waitForTmuxPromptSubmit({
173
+ tmuxRuntimeManager: host.tmuxRuntimeManager,
174
+ target,
175
+ draft: prompt,
176
+ isTargetCurrent: () => resolveLiveSessionTmuxTarget(host, sessionId, target)?.windowId === target.windowId,
177
+ });
171
178
  }
172
179
  else {
173
180
  session.write(text);
@@ -1,10 +1,11 @@
1
1
  import { type ToolConfig } from "../config.js";
2
+ import { type LaunchOverride } from "../shell-args.js";
2
3
  type ToolPickerHost = any;
3
4
  export declare function buildToolPickerOverlayOutput(host: ToolPickerHost): string;
4
5
  export declare function buildToolOptionsOverlayOutput(host: ToolPickerHost): string;
5
6
  export declare function renderToolPicker(host: ToolPickerHost): void;
6
7
  export declare function runSelectedTool(host: ToolPickerHost, toolKey: string, tool: ToolConfig, opts?: {
7
- extraArgs?: string[];
8
+ override?: LaunchOverride;
8
9
  }): void;
9
10
  export declare function showToolPicker(host: ToolPickerHost, sourceSessionId?: string): void;
10
11
  export declare function handleToolPickerKey(host: ToolPickerHost, data: Buffer): void;
@@ -1,6 +1,6 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { parseKeys } from "../key-parser.js";
3
- import { parseShellArgs } from "../shell-args.js";
3
+ import { parseLaunchCommandLine } from "../shell-args.js";
4
4
  import { forkDashboardAgentWithFeedback, spawnDashboardAgentWithFeedback } from "./dashboard-ops.js";
5
5
  function enabledTools() {
6
6
  const config = loadConfig();
@@ -67,28 +67,37 @@ export function buildToolOptionsOverlayOutput(host) {
67
67
  }
68
68
  const [toolKey, tool] = selected;
69
69
  const buffer = host.toolOptionsBuffer ?? "";
70
- let parsedExtraArgs = [];
70
+ let override;
71
71
  let parseError = host.toolOptionsError;
72
72
  if (!parseError) {
73
73
  try {
74
- parsedExtraArgs = parseShellArgs(buffer);
74
+ override = parseLaunchCommandLine(buffer);
75
75
  }
76
76
  catch (error) {
77
77
  parseError = error instanceof Error ? error.message : String(error);
78
78
  }
79
79
  }
80
- const defaultArgs = tool.args.length ? tool.args.map(quoteShellArg).join(" ") : "(none)";
81
- const preview = commandPreview(tool.command, [...tool.args, ...parsedExtraArgs]);
82
80
  const lines = [
83
81
  host.pickerMode === "fork" && host.forkSourceSessionId
84
- ? `Fork ${toolKey}: launch options`
85
- : `${toolKey}: launch options`,
82
+ ? `Fork ${toolKey}: launch command`
83
+ : `${toolKey}: launch command`,
86
84
  "",
87
- ` Default args: ${defaultArgs}`,
88
- ` Extra args: ${buffer}_`,
89
- "",
90
- ` Command: ${preview}`,
85
+ ` ${buffer}_`,
91
86
  ];
87
+ if (override) {
88
+ lines.push("");
89
+ if (override.env) {
90
+ const envStr = Object.entries(override.env)
91
+ .map(([key, value]) => `${key}=${quoteShellArg(value)}`)
92
+ .join(" ");
93
+ lines.push(` Env: ${envStr}`);
94
+ }
95
+ lines.push(` Launch: ${commandPreview(override.command, override.args)}`);
96
+ lines.push("");
97
+ lines.push(override.command === tool.command
98
+ ? " aimux hooks + session tracking applied"
99
+ : " custom binary — launched without aimux hooks");
100
+ }
92
101
  if (parseError) {
93
102
  lines.push("");
94
103
  lines.push(` Error: ${parseError}`);
@@ -106,8 +115,7 @@ export function renderToolPicker(host) {
106
115
  }
107
116
  export function runSelectedTool(host, toolKey, tool, opts = {}) {
108
117
  const wtPath = host.mode === "dashboard" ? host.dashboardState.focusedWorktreePath : undefined;
109
- const extraArgs = opts.extraArgs ?? [];
110
- const launchArgs = [...tool.args, ...extraArgs];
118
+ const override = opts.override;
111
119
  if (host.pickerMode === "fork") {
112
120
  const sourceSessionId = host.forkSourceSessionId;
113
121
  host.pickerMode = "create";
@@ -127,7 +135,7 @@ export function runSelectedTool(host, toolKey, tool, opts = {}) {
127
135
  targetSessionId,
128
136
  tool: toolKey,
129
137
  worktreePath: wtPath,
130
- extraArgs,
138
+ launchOverride: override,
131
139
  });
132
140
  return;
133
141
  }
@@ -137,7 +145,7 @@ export function runSelectedTool(host, toolKey, tool, opts = {}) {
137
145
  targetSessionId,
138
146
  targetWorktreePath: wtPath,
139
147
  open: false,
140
- extraArgs,
148
+ launchOverride: override,
141
149
  });
142
150
  return;
143
151
  }
@@ -153,11 +161,11 @@ export function runSelectedTool(host, toolKey, tool, opts = {}) {
153
161
  sessionId,
154
162
  tool: toolKey,
155
163
  worktreePath: wtPath,
156
- extraArgs,
164
+ launchOverride: override,
157
165
  });
158
166
  return;
159
167
  }
160
- host.createSession(tool.command, launchArgs, tool.preambleFlag, toolKey, undefined, tool.sessionIdFlag, wtPath, undefined, sessionId);
168
+ host.createSession(override?.command ?? tool.command, override?.args ?? tool.args, tool.preambleFlag, toolKey, undefined, tool.sessionIdFlag, wtPath, undefined, sessionId, false, false, undefined, override?.env);
161
169
  }
162
170
  export function showToolPicker(host, sourceSessionId) {
163
171
  host.pickerMode = sourceSessionId ? "fork" : "create";
@@ -204,7 +212,7 @@ export function handleToolPickerKey(host, data) {
204
212
  return;
205
213
  }
206
214
  host.toolOptionsToolKey = picked[0];
207
- host.toolOptionsBuffer = "";
215
+ host.toolOptionsBuffer = commandPreview(picked[1].command, picked[1].args);
208
216
  host.toolOptionsError = null;
209
217
  host.openDashboardOverlay("tool-options");
210
218
  if (typeof host.redrawDashboardWithOverlay === "function") {
@@ -271,9 +279,9 @@ export function handleToolOptionsKey(host, data) {
271
279
  return;
272
280
  }
273
281
  const [toolKey, tool] = selected;
274
- let extraArgs;
282
+ let override;
275
283
  try {
276
- extraArgs = parseShellArgs(host.toolOptionsBuffer ?? "");
284
+ override = parseLaunchCommandLine(host.toolOptionsBuffer ?? "");
277
285
  }
278
286
  catch (error) {
279
287
  host.toolOptionsError = error instanceof Error ? error.message : String(error);
@@ -286,7 +294,7 @@ export function handleToolOptionsKey(host, data) {
286
294
  return;
287
295
  }
288
296
  host.clearDashboardOverlay();
289
- runSelectedTool(host, toolKey, tool, { extraArgs });
297
+ runSelectedTool(host, toolKey, tool, { override });
290
298
  return;
291
299
  }
292
300
  if (event.name === "paste" || event.char) {
package/dist/notify.d.ts CHANGED
@@ -7,7 +7,7 @@ export declare function notifyPrompt(sessionId: string): void;
7
7
  export declare function notifyError(sessionId: string, message?: string): void;
8
8
  /** Notify that an agent completed (exited cleanly) */
9
9
  export declare function notifyComplete(sessionId: string): void;
10
- export declare function notifyAlert(event: AlertEvent): void;
10
+ export declare function notifyAlert(event: AlertEvent): boolean;
11
11
  export declare function notifyRemoteClientConnected(input: {
12
12
  title?: unknown;
13
13
  body?: unknown;