@brimveyn/aimux 1.21.0 → 1.22.2

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 (220) hide show
  1. package/README.md +27 -25
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +22 -27
  4. package/src/app-runtime/backend-attach-runtime.ts +43 -36
  5. package/src/app-runtime/backend-runtime-events.ts +107 -88
  6. package/src/app-runtime/editor-actions.ts +201 -0
  7. package/src/app-runtime/git-actions.ts +197 -0
  8. package/src/app-runtime/measure-ref.ts +19 -0
  9. package/src/app-runtime/navigation-actions.ts +213 -0
  10. package/src/app-runtime/project-actions.ts +188 -0
  11. package/src/app-runtime/prompt-injection.ts +123 -0
  12. package/src/app-runtime/selection.ts +29 -0
  13. package/src/app-runtime/settings-actions.ts +156 -0
  14. package/src/app-runtime/setup-actions.ts +237 -0
  15. package/src/app-runtime/side-effect-context.ts +33 -0
  16. package/src/app-runtime/side-effects.ts +302 -1541
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/tab-actions.ts +218 -0
  19. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  20. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  21. package/src/app-runtime/use-backend-runtime.ts +12 -11
  22. package/src/app-runtime/use-directory-search.ts +5 -4
  23. package/src/app-runtime/use-mouse-handlers.ts +2 -1
  24. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  25. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  26. package/src/app-runtime/use-setup-runner.ts +96 -0
  27. package/src/app-runtime/use-terminal-resize.ts +18 -14
  28. package/src/app-runtime/workspace-actions.ts +366 -0
  29. package/src/app-runtime/workspace-activity.ts +138 -0
  30. package/src/app-runtime/workspace-naming.ts +90 -0
  31. package/src/app.tsx +93 -48
  32. package/src/assets/sounds/bell.wav +0 -0
  33. package/src/assets/sounds/ding.wav +0 -0
  34. package/src/assets/sounds/plane.wav +0 -0
  35. package/src/cli/client/daemon-client.ts +2 -2
  36. package/src/cli/client/project-resolver.ts +112 -0
  37. package/src/cli/commands/project/close.ts +32 -0
  38. package/src/cli/commands/project/create.ts +93 -0
  39. package/src/cli/commands/project/list.ts +25 -0
  40. package/src/cli/commands/project/show.ts +32 -0
  41. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  42. package/src/cli/commands/tab/await.ts +2 -2
  43. package/src/cli/commands/tab/close.ts +3 -3
  44. package/src/cli/commands/tab/create.ts +74 -73
  45. package/src/cli/commands/tab/focus.ts +3 -3
  46. package/src/cli/commands/tab/list.ts +6 -6
  47. package/src/cli/commands/tab/run.ts +2 -2
  48. package/src/cli/commands/tab/send.ts +2 -2
  49. package/src/cli/commands/tab/snapshot.ts +2 -2
  50. package/src/cli/commands/tab/tail.ts +2 -2
  51. package/src/cli/commands/tab/wait.ts +2 -2
  52. package/src/cli/commands/worker/await.ts +3 -3
  53. package/src/cli/commands/worker/doctor.ts +30 -30
  54. package/src/cli/commands/worker/list.ts +15 -15
  55. package/src/cli/commands/worker/prompt.ts +3 -3
  56. package/src/cli/commands/worker/run.ts +23 -23
  57. package/src/cli/commands/worker/shared.ts +53 -59
  58. package/src/cli/commands/worker/stop.ts +29 -29
  59. package/src/cli/commands/worker/submit.ts +3 -3
  60. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  61. package/src/cli/commands/workspace/create.ts +30 -68
  62. package/src/cli/commands/workspace/list.ts +52 -9
  63. package/src/cli/commands/workspace/remove.ts +81 -0
  64. package/src/cli/completion/plan.ts +2 -2
  65. package/src/cli/completion/sources.ts +18 -18
  66. package/src/cli/context.ts +13 -13
  67. package/src/cli/flags.ts +30 -10
  68. package/src/cli/index.ts +26 -16
  69. package/src/cli/output.ts +1 -1
  70. package/src/cli/registry.ts +12 -12
  71. package/src/config/loader.ts +16 -5
  72. package/src/config.ts +70 -112
  73. package/src/daemon/catalog-writer.ts +67 -67
  74. package/src/daemon/daemon.ts +205 -201
  75. package/src/daemon/session-manager.ts +39 -39
  76. package/src/daemon/session-registry.ts +14 -14
  77. package/src/git/divergence.ts +33 -2
  78. package/src/git/git-poller.ts +1 -1
  79. package/src/git/git-status.ts +2 -2
  80. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  81. package/src/git/pr-status.ts +1 -1
  82. package/src/git/repo-discovery.ts +1 -1
  83. package/src/git/use-repo-discovery.ts +1 -1
  84. package/src/git/workspace-branch-poller.ts +54 -0
  85. package/src/git/workspace-divergence-poller.ts +65 -0
  86. package/src/git/worktree.ts +29 -0
  87. package/src/index.tsx +4 -4
  88. package/src/input/keymap/help-entries.ts +7 -5
  89. package/src/input/modes/bridge.ts +13 -12
  90. package/src/input/modes/handlers/shared.ts +1 -1
  91. package/src/input/modes/transitions.ts +34 -21
  92. package/src/input/modes/types.ts +48 -31
  93. package/src/ipc/manager-protocol.ts +44 -44
  94. package/src/ipc/protocol.ts +192 -166
  95. package/src/platform/play-sound.ts +184 -0
  96. package/src/platform/project-search.ts +8 -8
  97. package/src/platform/worktree-paths.ts +8 -6
  98. package/src/pty/assistant-question-extractor.ts +1 -1
  99. package/src/pty/assistant-status-detection-loop.ts +79 -55
  100. package/src/pty/assistant-status-detector.ts +40 -15
  101. package/src/pty/command-registry.ts +81 -1
  102. package/src/restart-terminal-manager.ts +1 -1
  103. package/src/services/ai-usage/provider.ts +9 -2
  104. package/src/services/ai-usage/spawn.ts +19 -6
  105. package/src/session-backend/bootstrap.ts +4 -4
  106. package/src/session-backend/local-session-backend.ts +78 -75
  107. package/src/session-backend/remote-session-backend.ts +64 -52
  108. package/src/session-backend/types.ts +43 -32
  109. package/src/settings/live.ts +90 -0
  110. package/src/settings/search.ts +43 -0
  111. package/src/settings/sections/about.ts +49 -0
  112. package/src/settings/sections/appearance.ts +54 -0
  113. package/src/settings/sections/automation.ts +105 -0
  114. package/src/settings/sections/commands.ts +72 -0
  115. package/src/settings/sections/editor.ts +56 -0
  116. package/src/settings/sections/experimental.ts +57 -0
  117. package/src/settings/sections/git.ts +112 -0
  118. package/src/settings/sections/index.ts +95 -0
  119. package/src/settings/sections/integrations.ts +23 -0
  120. package/src/settings/sections/layout.ts +72 -0
  121. package/src/settings/sections/notifications.ts +99 -0
  122. package/src/settings/sections/setup.ts +54 -0
  123. package/src/settings/sections/status-bar.ts +63 -0
  124. package/src/settings/settings-store.ts +207 -0
  125. package/src/settings/types.ts +108 -0
  126. package/src/snippets/run-shell-var.ts +48 -22
  127. package/src/state/actions.ts +342 -0
  128. package/src/state/app-store.ts +2 -1
  129. package/src/state/bars.ts +1 -1
  130. package/src/state/dispatch-ref.ts +1 -1
  131. package/src/state/layout-resize.ts +2 -2
  132. package/src/state/project-catalog.ts +275 -0
  133. package/src/state/project-data.ts +143 -0
  134. package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
  135. package/src/state/project-save.ts +46 -0
  136. package/src/state/project-workspaces.ts +392 -0
  137. package/src/state/reducers/auto-commit-state.ts +11 -10
  138. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  139. package/src/state/reducers/git-mode-state.ts +1 -1
  140. package/src/state/reducers/git-panel-state.ts +10 -6
  141. package/src/state/reducers/modal-state.ts +290 -681
  142. package/src/state/reducers/multi-repo-state.ts +3 -6
  143. package/src/state/reducers/project-state.ts +312 -0
  144. package/src/state/reducers/settings-state.ts +80 -0
  145. package/src/state/reducers/tab-state.ts +107 -60
  146. package/src/state/reducers/ui-state.ts +4 -3
  147. package/src/state/selectors.ts +49 -37
  148. package/src/state/store.ts +57 -24
  149. package/src/state/types.ts +244 -405
  150. package/src/state/validation.ts +14 -12
  151. package/src/terminal-manager/manager-client.ts +32 -32
  152. package/src/terminal-manager/terminal-manager.ts +24 -24
  153. package/src/ui/components/git/git-panel.tsx +2 -2
  154. package/src/ui/components/git/git-view.tsx +12 -12
  155. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  156. package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
  157. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  158. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  159. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  160. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  161. package/src/ui/components/layout/status-bar.tsx +6 -2
  162. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  163. package/src/ui/components/layout/top-tab-bar.tsx +46 -36
  164. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  165. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  166. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  167. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  168. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  169. package/src/ui/components/modals/shared/form.tsx +34 -22
  170. package/src/ui/components/modals/shared/picker.tsx +10 -10
  171. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  172. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  173. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  174. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  175. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  176. package/src/ui/components/primitives/input-field.tsx +7 -3
  177. package/src/ui/components/primitives/surface.tsx +3 -0
  178. package/src/ui/components/settings/row-value.tsx +74 -0
  179. package/src/ui/components/settings/settings-row.tsx +78 -0
  180. package/src/ui/components/settings/settings-view.tsx +169 -0
  181. package/src/ui/components/setup/setup-widget.tsx +152 -0
  182. package/src/ui/flash/build-labels.ts +27 -27
  183. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  184. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  185. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  186. package/src/ui/root.tsx +139 -121
  187. package/src/ui/status-bar-model.ts +48 -34
  188. package/src/ui/terminal-graphics/kitty.ts +28 -2
  189. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  190. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  191. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  192. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  193. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  194. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  195. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  196. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  197. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  198. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  199. package/src/ui/terminal-graphics/sprites.ts +246 -0
  200. package/src/ui/truncate.ts +7 -0
  201. package/src/ui/widgets/registry.tsx +6 -3
  202. package/src/ui/widgets/widget-context-menu.ts +26 -10
  203. package/src/update.ts +2 -2
  204. package/src/app-runtime/session-actions.ts +0 -187
  205. package/src/cli/client/workspace-resolver.ts +0 -110
  206. package/src/cli/commands/workspace/close.ts +0 -32
  207. package/src/cli/commands/workspace/show.ts +0 -32
  208. package/src/cli/commands/worktree/create.ts +0 -55
  209. package/src/cli/commands/worktree/list.ts +0 -68
  210. package/src/cli/commands/worktree/remove.ts +0 -81
  211. package/src/git/worktree-branch-poller.ts +0 -54
  212. package/src/git/worktree-divergence-poller.ts +0 -59
  213. package/src/state/reducers/session-state.ts +0 -262
  214. package/src/state/session-catalog.ts +0 -143
  215. package/src/state/session-worktrees.ts +0 -260
  216. package/src/state/workspace-save.ts +0 -43
  217. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  218. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  219. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  220. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -1,3 +1,5 @@
1
+ import { basename } from 'node:path'
2
+
1
3
  import type { AssistantId } from '../state/types'
2
4
 
3
5
  /**
@@ -22,6 +24,21 @@ export interface AssistantOption {
22
24
  command: string
23
25
  description: string
24
26
  model?: AssistantModelSpec
27
+ /**
28
+ * The CLI starts an interactive session with a positional prompt argument
29
+ * (`claude "…"`, `codex "…"`). When it does, handing the prompt over at spawn
30
+ * beats pasting it into the running TUI afterwards: no readiness poll, no
31
+ * screen probe, no retries.
32
+ *
33
+ * Absent means "unknown or not supported" and the caller falls back to
34
+ * `injectPromptWhenReady`. `opencode`'s positional is a project path and its
35
+ * `run` subcommand is non-interactive, so it stays on the fallback.
36
+ *
37
+ * ponytail: a boolean, because both CLIs that support it are positional.
38
+ * Promote to a builder — as `AssistantModelSpec` already does for flags — when
39
+ * a `--prompt <x>`-shaped one shows up.
40
+ */
41
+ acceptsPromptArg?: boolean
25
42
  }
26
43
 
27
44
  const DEFAULT_SHELL =
@@ -30,6 +47,7 @@ const SHELL_NAME = DEFAULT_SHELL.split('/').pop() ?? 'shell'
30
47
 
31
48
  export const ASSISTANT_OPTIONS: AssistantOption[] = [
32
49
  {
50
+ acceptsPromptArg: true,
33
51
  command: 'claude',
34
52
  description: 'Anthropic Claude CLI',
35
53
  id: 'claude',
@@ -40,6 +58,7 @@ export const ASSISTANT_OPTIONS: AssistantOption[] = [
40
58
  },
41
59
  },
42
60
  {
61
+ acceptsPromptArg: true,
43
62
  command: 'codex',
44
63
  description: 'OpenAI Codex CLI',
45
64
  id: 'codex',
@@ -116,12 +135,73 @@ export function getAllAssistantOptions(customCommands: Record<string, string>):
116
135
  return [...ASSISTANT_OPTIONS, ...customOptions]
117
136
  }
118
137
 
138
+ /**
139
+ * Whether this assistant can take the initial prompt as a spawn argument.
140
+ *
141
+ * A custom command counts only while it still runs the same program — extra
142
+ * flags are fine, a wrapper is not. A wrapper that forgets `"$@"` would swallow
143
+ * the prompt with no error anywhere, and pasting works for any command, so the
144
+ * unrecognised executable falls back rather than gambling.
145
+ */
146
+ export function assistantAcceptsPromptArg(
147
+ assistant: AssistantId,
148
+ customCommands: Record<string, string>
149
+ ): boolean {
150
+ const option = getAllAssistantOptions(customCommands).find((entry) => entry.id === assistant)
151
+ if (option?.acceptsPromptArg !== true) return false
152
+ const custom = customCommands[assistant]
153
+ if (custom == null || custom === '') return true
154
+ return basename(parseCommand(custom).executable) === option.command
155
+ }
156
+
119
157
  export function isCommandAvailable(command: string): boolean {
120
158
  return Bun.which(command) !== null
121
159
  }
122
160
 
161
+ /**
162
+ * Minimal POSIX shell-word splitter — respects single/double quotes and
163
+ * backslash escapes so values like `code --user-data-dir "/tmp/foo bar"` or a
164
+ * script path under a `$HOME` containing a space tokenize correctly.
165
+ * Does not expand variables or globs.
166
+ */
167
+ export function shellSplit(input: string): string[] {
168
+ const out: string[] = []
169
+ let current = ''
170
+ let inSingle = false
171
+ let inDouble = false
172
+ let hasToken = false
173
+ for (let i = 0; i < input.length; i++) {
174
+ const c = input[i] ?? ''
175
+ if (!inSingle && !inDouble && /\s/.test(c)) {
176
+ if (hasToken) {
177
+ out.push(current)
178
+ current = ''
179
+ hasToken = false
180
+ }
181
+ continue
182
+ }
183
+ hasToken = true
184
+ if (c === "'" && !inDouble) {
185
+ inSingle = !inSingle
186
+ } else if (c === '"' && !inSingle) {
187
+ inDouble = !inDouble
188
+ } else if (c === '\\' && !inSingle && i + 1 < input.length) {
189
+ current += input[++i]
190
+ } else {
191
+ current += c
192
+ }
193
+ }
194
+ if (hasToken) out.push(current)
195
+ return out
196
+ }
197
+
198
+ /** Wrap a value so `shellSplit` (and a real shell) return it as one word. */
199
+ export function shellQuote(value: string): string {
200
+ return `'${value.replaceAll("'", `'\\''`)}'`
201
+ }
202
+
123
203
  export function parseCommand(commandString: string): { executable: string; args: string[] } {
124
- const parts = commandString.trim().split(/\s+/).filter(Boolean)
204
+ const parts = shellSplit(commandString.trim())
125
205
  return { args: parts.slice(1), executable: parts[0] ?? '' }
126
206
  }
127
207
 
@@ -21,7 +21,7 @@ export async function runRestartTerminalManager(): Promise<number> {
21
21
  const managerPid = await findTerminalManagerPid()
22
22
  if (managerPid !== null) {
23
23
  process.stdout.write(
24
- `WARNING: killing terminal-manager (pid ${managerPid}) will kill live sessions.\n`
24
+ `WARNING: killing terminal-manager (pid ${managerPid}) will kill live projects.\n`
25
25
  )
26
26
  await killProcess(managerPid)
27
27
  process.stdout.write('Terminal-manager stopped.\n')
@@ -6,7 +6,14 @@ import { fetchClaudeUsage } from './adapters/claude'
6
6
  import { fetchCodexUsage } from './adapters/codex'
7
7
  import { loadCachedSnapshot, saveCachedSnapshot } from './cache'
8
8
 
9
- const DEFAULT_POLL_SECONDS = 60
9
+ /**
10
+ * Claude's OAuth usage endpoint drops a caller polling faster than this into a
11
+ * punitive rate-limit bucket, which shows up as an indicator that stops updating
12
+ * rather than as an error. So it is the floor as well as the default, and it is
13
+ * not per-tool: one interval drives every tool in the list, and a Codex-only
14
+ * setup loses nothing measurable by refreshing a quota bar every three minutes.
15
+ */
16
+ const MIN_POLL_SECONDS = 180
10
17
  const DEFAULT_TOOLS: AIUsageTool[] = ['claude', 'codex']
11
18
 
12
19
  export interface AIUsageServiceHandle {
@@ -28,7 +35,7 @@ export function startAIUsageService(
28
35
  onUpdate: (snap: UsageSnapshot) => void
29
36
  ): AIUsageServiceHandle {
30
37
  const tools = config.tools && config.tools.length > 0 ? config.tools : DEFAULT_TOOLS
31
- const pollMs = Math.max(5, config.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000
38
+ const pollMs = Math.max(MIN_POLL_SECONDS, config.pollSeconds ?? MIN_POLL_SECONDS) * 1000
32
39
 
33
40
  let stopped = false
34
41
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -13,12 +13,25 @@ export async function runCli(
13
13
  timeoutMs: number = DEFAULT_TIMEOUT_MS,
14
14
  cwd?: string
15
15
  ): Promise<CliResult> {
16
- const proc = Bun.spawn([command, ...args], {
17
- cwd,
18
- stderr: 'pipe',
19
- stdin: 'ignore',
20
- stdout: 'pipe',
21
- })
16
+ let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
17
+ try {
18
+ proc = Bun.spawn([command, ...args], {
19
+ cwd,
20
+ stderr: 'pipe',
21
+ stdin: 'ignore',
22
+ stdout: 'pipe',
23
+ })
24
+ } catch (error) {
25
+ // posix_spawn throws — synchronously — for a missing binary AND for a cwd
26
+ // that no longer exists (a deleted project dir or a pruned worktree), and
27
+ // it names the binary in both. Uncaught, that took the whole app down.
28
+ return {
29
+ error: `${command}: ${String(error)}`.slice(0, 200),
30
+ ok: false,
31
+ stderr: '',
32
+ stdout: '',
33
+ }
34
+ }
22
35
 
23
36
  const timeout = setTimeout(() => {
24
37
  try {
@@ -106,7 +106,7 @@ export async function probeDaemonProtocolCompatibility(
106
106
  const helloRequestId = crypto.randomUUID()
107
107
  const attachRequestId = crypto.randomUUID()
108
108
  const disposeRequestId = crypto.randomUUID()
109
- const probeSessionId = `probe-${crypto.randomUUID()}`
109
+ const probeProjectId = `probe-${crypto.randomUUID()}`
110
110
  let daemonProcessVersion: string | undefined
111
111
  let daemonCapabilities: readonly string[] = []
112
112
  let daemonManagerSelectedVersion: number | undefined
@@ -167,9 +167,9 @@ export async function probeDaemonProtocolCompatibility(
167
167
  id: attachRequestId,
168
168
  payload: {
169
169
  cols: 80,
170
+ projectId: probeProjectId,
170
171
  protocolVersion: message.payload.selectedVersion,
171
172
  rows: 24,
172
- sessionId: probeSessionId,
173
173
  },
174
174
  type: 'attach',
175
175
  })
@@ -311,7 +311,7 @@ export async function createSessionBackend(opts?: {
311
311
  // Ring 3: prefer hot-reexec over the legacy stopTM+restart path when
312
312
  // the running daemon advertises `hotReexec` and the operator has opted
313
313
  // in via AIMUX_HOT_REEXEC=1. The terminal-manager and every PTY stay
314
- // alive; the daemon binary swaps under them — no session-loss prompt.
314
+ // alive; the daemon binary swaps under them — no project-loss prompt.
315
315
  const reexecEnabled = process.env.AIMUX_HOT_REEXEC === '1'
316
316
  const daemonSupportsReexec =
317
317
  handshake.capabilities?.includes(IPC_CAPABILITY_HOT_REEXEC) ?? false
@@ -363,7 +363,7 @@ export async function createSessionBackend(opts?: {
363
363
 
364
364
  // We're about to kill the terminal-manager and every PTY — warn the UI
365
365
  // now so the user isn't surprised. The hot-reexec branch above never
366
- // reaches this point, so the callback fires only when sessions really
366
+ // reaches this point, so the callback fires only when projects really
367
367
  // do die.
368
368
  await opts?.onBreakingUpdateRequired?.()
369
369
 
@@ -2,11 +2,11 @@ import { EventEmitter } from 'node:events'
2
2
 
3
3
  import type {
4
4
  AssistantId,
5
+ ProjectSnapshotV1,
5
6
  TerminalModeState,
6
7
  TerminalSnapshot,
7
- WorkspaceSnapshotV1,
8
8
  } from '../state/types'
9
- import type { ResizeOptions, SessionBackend, SessionBackendEvents } from './types'
9
+ import type { ProjectBackendEvents, ResizeOptions, SessionBackend } from './types'
10
10
 
11
11
  import {
12
12
  type AutoRenameConfigSnapshot,
@@ -24,11 +24,11 @@ import {
24
24
  } from '../state/layout-resize'
25
25
 
26
26
  export class LocalSessionBackend
27
- extends EventEmitter<SessionBackendEvents>
27
+ extends EventEmitter<ProjectBackendEvents>
28
28
  implements SessionBackend
29
29
  {
30
30
  private readonly sessionManager = new SessionManager()
31
- private currentSessionId: string | null = null
31
+ private currentProjectId: string | null = null
32
32
  private readonly statusLoop: ReturnType<typeof runStatusDetectionLoop>
33
33
  /**
34
34
  * Per-tab snapshot gate. While `false`, render events from the underlying
@@ -36,8 +36,8 @@ export class LocalSessionBackend
36
36
  * the UI. The frontend lifts the gate once `usePaneSizeReport` has reported
37
37
  * the *actual* rendered pane size via `resizeTab({ confirmedFromMeasurement })`,
38
38
  * so the first snapshot the UI ever paints is sized against the real box —
39
- * never against the open-loop chrome estimate used at session create / attach.
40
- * A tab without an entry here is treated as ready (legacy paths, sessions
39
+ * never against the open-loop chrome estimate used at project create / attach.
40
+ * A tab without an entry here is treated as ready (legacy paths, projects
41
41
  * not subject to the gate). See plan: il-y-a-eu-stateful-boot.md.
42
42
  */
43
43
  private readonly paneReady = new Map<string, boolean>()
@@ -59,89 +59,92 @@ export class LocalSessionBackend
59
59
  updateTab: (tabId, patch) => {
60
60
  const found = this.findTab(tabId)
61
61
  if (!found) return
62
- this.sessionManager.updateTabMetadata(found.sessionId, tabId, patch)
63
- if (found.sessionId === this.currentSessionId) {
64
- this.emit('tabMetadataUpdated', found.sessionId, tabId, patch)
62
+ this.sessionManager.updateTabMetadata(found.projectId, tabId, patch)
63
+ if (found.projectId === this.currentProjectId) {
64
+ this.emit('tabMetadataUpdated', found.projectId, tabId, patch)
65
65
  }
66
66
  },
67
67
  })
68
- this.sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
69
- if (sessionId !== this.currentSessionId) return
68
+ this.sessionManager.on('render', (projectId, tabId, viewport, terminalModes) => {
69
+ if (projectId !== this.currentProjectId) return
70
70
  if (this.paneReady.get(tabId) === false) {
71
71
  this.pendingRender.set(tabId, { terminalModes, viewport })
72
72
  return
73
73
  }
74
74
  this.emit('render', tabId, viewport, terminalModes)
75
75
  })
76
- this.sessionManager.on('exit', (sessionId, tabId, exitCode) => {
77
- if (sessionId === this.currentSessionId) {
76
+ this.sessionManager.on('exit', (projectId, tabId, exitCode) => {
77
+ if (projectId === this.currentProjectId) {
78
78
  this.emit('exit', tabId, exitCode)
79
79
  }
80
80
  })
81
- this.sessionManager.on('error', (sessionId, tabId, message) => {
82
- if (sessionId === this.currentSessionId) {
81
+ this.sessionManager.on('error', (projectId, tabId, message) => {
82
+ if (projectId === this.currentProjectId) {
83
83
  this.emit('error', tabId, message)
84
84
  }
85
85
  })
86
86
  this.statusLoop = runStatusDetectionLoop({
87
- listSessions: () => this.sessionManager.listSessionIds(),
88
- listTabs: (sessionId) => this.sessionManager.listTabs(sessionId),
89
- onSessionStatus: (sessionId, status) => {
90
- this.emit('sessionActivity', sessionId, status)
87
+ listProjects: () => this.sessionManager.listSessionIds(),
88
+ listTabs: (projectId) => this.sessionManager.listTabs(projectId),
89
+ onProjectStatus: (projectId, status) => {
90
+ this.emit('projectActivity', projectId, status)
91
91
  },
92
- onTabStatus: (tabId, status, sessionId) => {
93
- if (sessionId === this.currentSessionId) {
94
- this.emit('tabActivity', tabId, status)
92
+ onTabStatus: (tabId, status, projectId, workspaceId) => {
93
+ if (projectId === this.currentProjectId) {
94
+ this.emit('tabActivity', tabId, status, workspaceId)
95
95
  }
96
96
  },
97
+ onTurnComplete: (tabId, projectId, _idleMs, workspaceId) => {
98
+ this.emit('tabTurnComplete', tabId, projectId, workspaceId)
99
+ },
97
100
  })
98
101
  }
99
102
 
100
103
  async attach(options: {
101
- sessionId: string
104
+ projectId: string
102
105
  cols: number
103
106
  rows: number
104
- workspaceSnapshot?: WorkspaceSnapshotV1
107
+ projectSnapshot?: ProjectSnapshotV1
105
108
  }) {
106
109
  logDebug('backend.local.attach', {
107
110
  cols: options.cols,
111
+ projectId: options.projectId,
108
112
  rows: options.rows,
109
- sessionId: options.sessionId,
110
- snapshotTabs: options.workspaceSnapshot?.tabs.length ?? 0,
113
+ snapshotTabs: options.projectSnapshot?.tabs.length ?? 0,
111
114
  })
112
- this.currentSessionId = options.sessionId
113
- const trees = getSnapshotTrees(options.workspaceSnapshot)
115
+ this.currentProjectId = options.projectId
116
+ const trees = getSnapshotTrees(options.projectSnapshot)
114
117
  const splitTrees = trees.filter((t) => t.type === 'split')
115
118
  if (splitTrees.length > 0) {
116
119
  const bounds = createTerminalBounds(options.cols, options.rows)
117
120
  forEachSplitPaneRect(splitTrees, bounds, (tabId, rect) => {
118
121
  const size = toTerminalContentSize(rect)
119
122
  this.gatePaneRender(tabId)
120
- this.sessionManager.resizeTab(options.sessionId, tabId, size.cols, size.rows)
123
+ this.sessionManager.resizeTab(options.projectId, tabId, size.cols, size.rows)
121
124
  })
122
125
  } else {
123
- this.sessionManager.resize(options.sessionId, options.cols, options.rows)
126
+ this.sessionManager.resize(options.projectId, options.cols, options.rows)
124
127
  }
125
128
  const attachResult = this.sessionManager.attachSession(
126
- options.sessionId,
127
- options.workspaceSnapshot
129
+ options.projectId,
130
+ options.projectSnapshot
128
131
  )
129
132
  for (const tab of attachResult.tabs) {
130
133
  this.gatePaneRender(tab.id)
131
134
  this.autoRename.register(tab)
132
135
  }
133
136
  // Run a synchronous classification pass so every tab's activity and the
134
- // session-status snapshot are available to embed in the reply — mirrors
137
+ // project-status snapshot are available to embed in the reply — mirrors
135
138
  // the remote backend's behavior and keeps hydrate dispatches atomic on
136
139
  // the client side.
137
- this.statusLoop.classifyNow(options.sessionId, this.sessionManager.listTabs(options.sessionId))
140
+ this.statusLoop.classifyNow(options.projectId, this.sessionManager.listTabs(options.projectId))
138
141
  const tabsWithActivity = attachResult.tabs.map((tab) => ({
139
142
  ...tab,
140
143
  activity: this.statusLoop.getTabStatus(tab.id) ?? tab.activity,
141
144
  }))
142
145
  return {
143
146
  activeTabId: attachResult.activeTabId,
144
- initialSessionStatuses: this.statusLoop.snapshotSessions(),
147
+ initialProjectStatuses: this.statusLoop.snapshotProjects(),
145
148
  tabs: tabsWithActivity,
146
149
  }
147
150
  }
@@ -155,18 +158,18 @@ export class LocalSessionBackend
155
158
  cols: number
156
159
  rows: number
157
160
  cwd?: string
158
- worktreeId?: string
161
+ workspaceId?: string
159
162
  autoRenameCandidate?: boolean
160
163
  }): void {
161
- if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
162
- logDebug('backend.local.skipCreateWithoutSession', { tabId: options.tabId })
164
+ if (!(this.currentProjectId != null && this.currentProjectId !== '')) {
165
+ logDebug('backend.local.skipCreateWithoutProject', { tabId: options.tabId })
163
166
  return
164
167
  }
165
168
  logDebug('backend.local.createSession', {
166
- sessionId: this.currentSessionId,
169
+ projectId: this.currentProjectId,
167
170
  tabId: options.tabId,
168
171
  title: options.title,
169
- worktreeId: options.worktreeId ?? null,
172
+ workspaceId: options.workspaceId ?? null,
170
173
  })
171
174
  this.gatePaneRender(options.tabId)
172
175
  const autoRenameStatus = initialAutoRenameStatus(
@@ -174,7 +177,7 @@ export class LocalSessionBackend
174
177
  options.assistant,
175
178
  options.autoRenameCandidate === true
176
179
  )
177
- this.sessionManager.createTab(this.currentSessionId, { ...options, autoRenameStatus })
180
+ this.sessionManager.createTab(this.currentProjectId, { ...options, autoRenameStatus })
178
181
  const tab = this.findTab(options.tabId)?.tab
179
182
  if (tab) this.autoRename.register(tab)
180
183
  }
@@ -197,17 +200,17 @@ export class LocalSessionBackend
197
200
  }
198
201
 
199
202
  write(tabId: string, input: string): void {
200
- if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
201
- logDebug('backend.local.skipWriteWithoutSession', { inputLength: input.length, tabId })
203
+ if (!(this.currentProjectId != null && this.currentProjectId !== '')) {
204
+ logDebug('backend.local.skipWriteWithoutProject', { inputLength: input.length, tabId })
202
205
  return
203
206
  }
204
207
  logDebug('backend.local.write', {
205
208
  inputLength: input.length,
206
- sessionId: this.currentSessionId,
209
+ projectId: this.currentProjectId,
207
210
  tabId,
208
211
  })
209
212
  this.autoRename.observeWrite(tabId, input)
210
- this.sessionManager.write(this.currentSessionId, tabId, input)
213
+ this.sessionManager.write(this.currentProjectId, tabId, input)
211
214
  }
212
215
 
213
216
  renameTab(tabId: string, title: string): void {
@@ -215,80 +218,80 @@ export class LocalSessionBackend
215
218
  if (!found) return
216
219
  this.autoRename.manualRename(tabId)
217
220
  const patch = { autoRenameStatus: 'attempted' as const, title }
218
- this.sessionManager.updateTabMetadata(found.sessionId, tabId, patch)
219
- if (found.sessionId === this.currentSessionId) {
220
- this.emit('tabMetadataUpdated', found.sessionId, tabId, patch)
221
+ this.sessionManager.updateTabMetadata(found.projectId, tabId, patch)
222
+ if (found.projectId === this.currentProjectId) {
223
+ this.emit('tabMetadataUpdated', found.projectId, tabId, patch)
221
224
  }
222
225
  }
223
226
 
224
227
  scrollViewport(tabId: string, deltaLines: number): void {
225
- if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
226
- this.sessionManager.scroll(this.currentSessionId, tabId, deltaLines)
228
+ if (!(this.currentProjectId != null && this.currentProjectId !== '')) return
229
+ this.sessionManager.scroll(this.currentProjectId, tabId, deltaLines)
227
230
  }
228
231
 
229
232
  scrollViewportToBottom(tabId: string): void {
230
- if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
231
- this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
233
+ if (!(this.currentProjectId != null && this.currentProjectId !== '')) return
234
+ this.sessionManager.scrollToBottom(this.currentProjectId, tabId)
232
235
  }
233
236
 
234
237
  setActiveTab(tabId: string | null): void {
235
- if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
236
- logDebug('backend.local.setActiveTab', { sessionId: this.currentSessionId, tabId })
237
- this.sessionManager.setActiveTab(this.currentSessionId, tabId)
238
+ if (!(this.currentProjectId != null && this.currentProjectId !== '')) return
239
+ logDebug('backend.local.setActiveTab', { projectId: this.currentProjectId, tabId })
240
+ this.sessionManager.setActiveTab(this.currentProjectId, tabId)
238
241
  }
239
242
 
240
243
  resizeAll(cols: number, rows: number, options?: ResizeOptions): void {
241
- if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
242
- this.sessionManager.resize(this.currentSessionId, cols, rows, options)
244
+ if (!(this.currentProjectId != null && this.currentProjectId !== '')) return
245
+ this.sessionManager.resize(this.currentProjectId, cols, rows, options)
243
246
  }
244
247
 
245
248
  resizeTab(tabId: string, cols: number, rows: number, options?: ResizeOptions): void {
246
- if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
247
- this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, options)
249
+ if (!(this.currentProjectId != null && this.currentProjectId !== '')) return
250
+ this.sessionManager.resizeTab(this.currentProjectId, tabId, cols, rows, options)
248
251
  if (options?.confirmedFromMeasurement === true) {
249
252
  this.releasePaneRender(tabId)
250
253
  }
251
254
  }
252
255
 
253
256
  disposeSession(tabId: string): void {
254
- if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
255
- logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
257
+ if (!(this.currentProjectId != null && this.currentProjectId !== '')) return
258
+ logDebug('backend.local.disposeSession', { projectId: this.currentProjectId, tabId })
256
259
  this.paneReady.delete(tabId)
257
260
  this.pendingRender.delete(tabId)
258
261
  this.autoRename.unregister(tabId)
259
- this.sessionManager.closeTab(this.currentSessionId, tabId)
262
+ this.sessionManager.closeTab(this.currentProjectId, tabId)
260
263
  }
261
264
 
262
265
  disposeAll(): void {
263
- if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
264
- logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
266
+ if (!(this.currentProjectId != null && this.currentProjectId !== '')) return
267
+ logDebug('backend.local.disposeAll', { projectId: this.currentProjectId })
265
268
  this.paneReady.clear()
266
269
  this.pendingRender.clear()
267
- for (const tab of this.sessionManager.listTabs(this.currentSessionId)) {
270
+ for (const tab of this.sessionManager.listTabs(this.currentProjectId)) {
268
271
  this.autoRename.unregister(tab.id)
269
272
  }
270
- this.sessionManager.disposeSession(this.currentSessionId)
273
+ this.sessionManager.disposeSession(this.currentProjectId)
271
274
  }
272
275
 
273
276
  destroy(keepSessions = true): void {
274
- logDebug('backend.local.destroy', { keepSessions, sessionId: this.currentSessionId })
275
- if (!keepSessions && this.currentSessionId != null && this.currentSessionId !== '') {
276
- this.sessionManager.disposeSession(this.currentSessionId)
277
+ logDebug('backend.local.destroy', { keepSessions, projectId: this.currentProjectId })
278
+ if (!keepSessions && this.currentProjectId != null && this.currentProjectId !== '') {
279
+ this.sessionManager.disposeSession(this.currentProjectId)
277
280
  }
278
281
  this.statusLoop.stop()
279
- this.currentSessionId = null
282
+ this.currentProjectId = null
280
283
  }
281
284
 
282
- announceWorkspaceSwitched(_sessionId: string): void {
285
+ announceProjectSwitched(_projectId: string): void {
283
286
  // No daemon on the local backend, so there's no CLI to notify.
284
287
  }
285
288
 
286
289
  private findTab(
287
290
  tabId: string
288
- ): { sessionId: string; tab: ReturnType<SessionManager['listTabs']>[number] } | null {
289
- for (const sessionId of this.sessionManager.listSessionIds()) {
290
- const tab = this.sessionManager.listTabs(sessionId).find((entry) => entry.id === tabId)
291
- if (tab) return { sessionId, tab }
291
+ ): { projectId: string; tab: ReturnType<SessionManager['listTabs']>[number] } | null {
292
+ for (const projectId of this.sessionManager.listSessionIds()) {
293
+ const tab = this.sessionManager.listTabs(projectId).find((entry) => entry.id === tabId)
294
+ if (tab) return { projectId, tab }
292
295
  }
293
296
  return null
294
297
  }