@brimveyn/aimux 1.19.3 → 1.19.5

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.
@@ -7,7 +7,6 @@
7
7
  * timeout trips. Exactly one JSON object is emitted and the exit code encodes
8
8
  * the outcome, so a driver can branch without re-snapshotting the screen.
9
9
  */
10
- import type { QuestionKind } from '../../../state/types'
11
10
  import type { CliCommand } from '../../registry'
12
11
 
13
12
  import {
@@ -16,73 +15,9 @@ import {
16
15
  IPC_CAPABILITY_TURN_LIFECYCLE,
17
16
  } from '../../../ipc/protocol'
18
17
  import { SHARED_FLAGS } from '../../flags'
19
- import { EXIT_OK, EXIT_QUESTION, EXIT_RUNTIME, EXIT_TIMEOUT, writeJson } from '../../output'
20
- import { buildPromptPayload, writePromptPayload } from './prompt-io'
21
-
22
- /** Overall cap on a single turn — 15 min, long enough for a heavy build task. */
23
- const DEFAULT_TIMEOUT_MS = 900_000
24
-
25
- /**
26
- * The four terminal shapes of a `tab run`. Modelled as a discriminated union so
27
- * the JSON we emit and the exit code we return are derived from one value, and
28
- * so the outcome→exit mapping can be unit-tested without a live daemon.
29
- * `durationMs` is measured from prompt submit, not attach, so it reflects the
30
- * worker's think time rather than our connection overhead.
31
- */
32
- export type RunOutcome =
33
- | { durationMs: number; outcome: 'completed' }
34
- | { durationMs: number; error: string; outcome: 'error' }
35
- | {
36
- durationMs: number
37
- kind: QuestionKind
38
- options?: string[]
39
- outcome: 'question'
40
- question: string
41
- }
42
- | { durationMs: number; outcome: 'timeout' }
43
-
44
- /**
45
- * Map an outcome to its process exit code. Pure and total over the union so a
46
- * driver's `case $?` stays exhaustive: 0 completed, 10 question/permission
47
- * (worker is blocked and wants input), 3 the tab errored/exited, 124 we hit the
48
- * overall cap.
49
- */
50
- export function outcomeExitCode(outcome: RunOutcome): number {
51
- switch (outcome.outcome) {
52
- case 'completed':
53
- return EXIT_OK
54
- case 'question':
55
- return EXIT_QUESTION
56
- case 'error':
57
- return EXIT_RUNTIME
58
- case 'timeout':
59
- return EXIT_TIMEOUT
60
- }
61
- }
62
-
63
- /**
64
- * Resolve the prompt text from exactly one source. We require exactly one of
65
- * `--prompt-file`, `--stdin`, or the positional `[text]` so an orchestrator
66
- * never silently sends the wrong buffer when two sources are set (e.g. a stale
67
- * positional plus a fresh `--prompt-file`).
68
- */
69
- async function resolvePromptText(
70
- promptFile: string | undefined,
71
- fromStdin: boolean,
72
- positionalText: string | undefined
73
- ): Promise<string> {
74
- const sources = [promptFile !== undefined, fromStdin, positionalText !== undefined].filter(
75
- (present) => present
76
- ).length
77
- if (sources !== 1) {
78
- throw new Error(
79
- 'provide exactly one prompt source: --prompt-file <f>, --stdin, or a [text] positional'
80
- )
81
- }
82
- if (promptFile !== undefined) return Bun.file(promptFile).text()
83
- if (fromStdin) return Bun.stdin.text()
84
- return positionalText ?? ''
85
- }
18
+ import { writeJson } from '../../output'
19
+ import { awaitTurn, DEFAULT_TIMEOUT_MS, turnOutcomeExitCode } from './await-turn'
20
+ import { buildPromptPayload, resolvePromptText, writePromptPayload } from './prompt-io'
86
21
 
87
22
  export const tabRun: CliCommand = {
88
23
  args: [{ name: 'tabId', required: true }, { name: 'text' }],
@@ -136,86 +71,20 @@ export const tabRun: CliCommand = {
136
71
 
137
72
  const payload = buildPromptPayload(text, false)
138
73
 
139
- return new Promise<number>((resolve) => {
140
- // Subscribe BEFORE writing: these events fire only on transitions, so a
141
- // late subscription would race the worker starting its turn.
142
- let start = Date.now()
143
- // Uptake guard. The tab may sit `idle` from a prior turn; a stale
144
- // `tabTurnComplete` (or the settle window closing on that old idle) must
145
- // not read as "this turn completed". Only honour completion once we've
146
- // seen the tab go `working` after our submit.
147
- let sawWorking = false
148
-
149
- const settle = (outcome: RunOutcome): void => {
150
- cleanup()
151
- writeJson(outcome)
152
- resolve(outcomeExitCode(outcome))
153
- }
154
- const durationMs = (): number => Date.now() - start
155
-
156
- const offStatus = daemon.on('tabStatus', (p) => {
157
- if (p.tabId !== tabId) return
158
- if (p.status === 'working') sawWorking = true
159
- })
160
- const offTurn = daemon.on('tabTurnComplete', (p) => {
161
- if (p.tabId !== tabId) return
162
- // Ignore end-of-turn until the worker actually started working, so a
163
- // lingering pre-submit idle can't be mis-read as completion.
164
- if (!sawWorking) return
165
- settle({ durationMs: durationMs(), outcome: 'completed' })
166
- })
167
- const offQuestion = daemon.on('tabQuestion', (p) => {
168
- if (p.tabId !== tabId) return
169
- // A question is honoured immediately — it can legitimately arrive
170
- // before `working` (the worker asks before doing anything).
171
- settle({
172
- durationMs: durationMs(),
173
- kind: p.kind,
174
- options: p.options,
175
- outcome: 'question',
176
- question: p.prompt,
177
- })
178
- })
179
- const offExit = daemon.on('tabExit', (p) => {
180
- if (p.tabId !== tabId) return
181
- settle({ durationMs: durationMs(), error: `exit ${p.exitCode}`, outcome: 'error' })
182
- })
183
- const offError = daemon.on('tabError', (p) => {
184
- if (p.tabId !== tabId) return
185
- settle({ durationMs: durationMs(), error: p.message, outcome: 'error' })
186
- })
187
-
188
- const timer = setTimeout(() => {
189
- settle({ durationMs: durationMs(), outcome: 'timeout' })
190
- }, timeoutMs)
191
-
192
- const cleanup = (): void => {
193
- offStatus()
194
- offTurn()
195
- offQuestion()
196
- offExit()
197
- offError()
198
- clearTimeout(timer)
199
- }
200
-
201
- // Submit after subscribing, then reset the clock so `durationMs` measures
202
- // the worker's turn rather than our attach/write overhead. On a write
203
- // failure the tab likely died — surface it as an error outcome rather
204
- // than sitting idle until the timeout.
205
- const submit = async (): Promise<void> => {
206
- try {
207
- await writePromptPayload(daemon, tabId, payload, appendEnter)
208
- start = Date.now()
209
- } catch (error) {
210
- settle({
211
- durationMs: durationMs(),
212
- error: error instanceof Error ? error.message : String(error),
213
- outcome: 'error',
214
- })
215
- }
216
- }
217
- void submit()
74
+ // Submit inside `onArmed` so subscriptions are live before the worker starts
75
+ // its turn; `assumeWorking: false` keeps the uptake guard, so a lingering
76
+ // pre-submit idle can't be misread as completion.
77
+ const outcome = await awaitTurn({
78
+ assumeWorking: false,
79
+ daemon,
80
+ onArmed: async () => {
81
+ await writePromptPayload(daemon, tabId, payload, appendEnter)
82
+ },
83
+ tabId,
84
+ timeoutMs,
218
85
  })
86
+ writeJson(outcome)
87
+ return turnOutcomeExitCode(outcome)
219
88
  },
220
89
  summary: 'Submit a prompt and block until the turn completes or the worker asks',
221
90
  verb: 'run',
@@ -23,6 +23,11 @@ export const tabSend: CliCommand = {
23
23
  kind: 'boolean',
24
24
  name: 'stdin',
25
25
  },
26
+ {
27
+ description: 'read the payload from this file instead of <text>',
28
+ kind: 'string',
29
+ name: 'prompt-file',
30
+ },
26
31
  {
27
32
  description:
28
33
  'after submitting, block until the tab transitions to working (uptake confirmed)',
@@ -44,6 +49,8 @@ export const tabSend: CliCommand = {
44
49
  }
45
50
 
46
51
  const fromStdin = ctx.args.flags.stdin === true
52
+ const promptFile =
53
+ typeof ctx.args.flags['prompt-file'] === 'string' ? ctx.args.flags['prompt-file'] : undefined
47
54
  const asKeys = ctx.args.flags.keys === true
48
55
  const appendEnter = ctx.args.flags.enter === true
49
56
  const awaitSubmit = ctx.args.flags['await-submit'] === true
@@ -60,7 +67,26 @@ export const tabSend: CliCommand = {
60
67
  throw new Error('--await-submit requires --enter')
61
68
  }
62
69
 
63
- const text = fromStdin ? await Bun.stdin.text() : (ctx.args.positionals[1] ?? '')
70
+ // At most one payload source. Unlike `tab run`, zero sources is valid here
71
+ // (`tab send <tab> --enter` submits an empty line), so we only reject
72
+ // conflicting combinations rather than requiring exactly one.
73
+ if (promptFile !== undefined) {
74
+ if (fromStdin) throw new Error('--prompt-file cannot be combined with --stdin')
75
+ if (asKeys)
76
+ throw new Error('--prompt-file cannot be combined with --keys (chords go in <text>)')
77
+ if (ctx.args.positionals[1] !== undefined) {
78
+ throw new Error('--prompt-file cannot be combined with a <text> positional')
79
+ }
80
+ }
81
+
82
+ let text: string
83
+ if (promptFile !== undefined) {
84
+ text = await Bun.file(promptFile).text()
85
+ } else if (fromStdin) {
86
+ text = await Bun.stdin.text()
87
+ } else {
88
+ text = ctx.args.positionals[1] ?? ''
89
+ }
64
90
  if (asKeys && text === '') {
65
91
  throw new Error('--keys requires the chord notation as <text>')
66
92
  }
@@ -0,0 +1,96 @@
1
+ import type { SessionRecord, WorktreeRecord } from '../../../state/types'
2
+ import type { DaemonClient } from '../../client/daemon-client'
3
+
4
+ import { createGitWorktree, removeGitWorktree } from '../../../git/worktree'
5
+ import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
6
+ import { createPrefixedId } from '../../../platform/id'
7
+ import {
8
+ assertSafeAimuxWorktreePath,
9
+ ensureAimuxWorktreeRoot,
10
+ makeWorktreePath,
11
+ } from '../../../platform/worktree-paths'
12
+
13
+ export interface CreateWorktreeParams {
14
+ /** Base ref for the branch (callers default to 'HEAD'). */
15
+ base: string
16
+ /** Branch name (callers default to `aimux/<name>`). */
17
+ branch: string
18
+ daemon: DaemonClient
19
+ name: string
20
+ workspace: SessionRecord
21
+ }
22
+
23
+ /**
24
+ * Create a git worktree + its catalog record for a workspace. Shared by
25
+ * `worktree create` and `tab create --new-worktree`. Checks the daemon
26
+ * capability BEFORE touching disk, and rolls back the on-disk worktree if
27
+ * catalog registration fails (so `worktree list` never surfaces an orphan).
28
+ * Throws on any failure; returns the registered record on success.
29
+ */
30
+ export async function createWorkspaceWorktree(
31
+ params: CreateWorktreeParams
32
+ ): Promise<WorktreeRecord> {
33
+ const { base, branch, daemon, name, workspace } = params
34
+
35
+ const primary = workspace.worktrees?.find((w) => w.source === 'primary')
36
+ if (!primary) {
37
+ throw new Error(
38
+ `workspace "${workspace.name}" has no primary worktree — set --project when creating it`
39
+ )
40
+ }
41
+
42
+ // Check the daemon's capability BEFORE mutating disk — otherwise a capability
43
+ // mismatch would leave a git worktree on disk with no catalog record.
44
+ if (!daemon.hasCapability(IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS)) {
45
+ throw new Error(
46
+ 'daemon predates worktreeLifecycleEvents capability — restart aimux to pick up the new daemon'
47
+ )
48
+ }
49
+
50
+ const worktreeId = createPrefixedId('worktree')
51
+ const targetPath = makeWorktreePath({
52
+ repoRoot: primary.repoRoot,
53
+ worktreeId,
54
+ worktreeName: name,
55
+ })
56
+ await ensureAimuxWorktreeRoot()
57
+ await assertSafeAimuxWorktreePath(targetPath)
58
+
59
+ await createGitWorktree({
60
+ baseRef: base,
61
+ branchName: branch,
62
+ repoPath: primary.repoRoot,
63
+ targetPath,
64
+ })
65
+
66
+ const now = new Date().toISOString()
67
+ const record: WorktreeRecord = {
68
+ baseRef: base,
69
+ branch,
70
+ createdAt: now,
71
+ createdByAimux: true,
72
+ id: worktreeId,
73
+ name,
74
+ path: targetPath,
75
+ repoRoot: primary.repoRoot,
76
+ source: 'aimux-temp',
77
+ updatedAt: now,
78
+ }
79
+
80
+ try {
81
+ await daemon.expectOk('addWorktreeRecord', { sessionId: workspace.id, worktree: record })
82
+ } catch (error) {
83
+ // Catalog registration failed — roll back the on-disk worktree so
84
+ // `worktree list` doesn't perpetually surface an orphan. Swallow rollback
85
+ // errors: report the original failure, the real problem to surface.
86
+ try {
87
+ await removeGitWorktree({ force: true, repoPath: primary.repoRoot, targetPath })
88
+ } catch {
89
+ // Best-effort rollback; leave the git-side worktree if it can't be removed
90
+ // cleanly. `worktree list` will flag it as gitTracked with no catalog.
91
+ }
92
+ throw error
93
+ }
94
+
95
+ return record
96
+ }
@@ -1,16 +1,8 @@
1
- import type { WorktreeRecord } from '../../../state/types'
2
1
  import type { CliCommand } from '../../registry'
3
2
 
4
- import { createGitWorktree, removeGitWorktree } from '../../../git/worktree'
5
- import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
6
- import { createPrefixedId } from '../../../platform/id'
7
- import {
8
- assertSafeAimuxWorktreePath,
9
- ensureAimuxWorktreeRoot,
10
- makeWorktreePath,
11
- } from '../../../platform/worktree-paths'
12
3
  import { SHARED_FLAGS } from '../../flags'
13
4
  import { EXIT_OK, writeJson } from '../../output'
5
+ import { createWorkspaceWorktree } from './create-core'
14
6
 
15
7
  export const worktreeCreate: CliCommand = {
16
8
  args: [],
@@ -31,80 +23,15 @@ export const worktreeCreate: CliCommand = {
31
23
  const base = typeof ctx.args.flags.base === 'string' ? ctx.args.flags.base : 'HEAD'
32
24
 
33
25
  const workspace = ctx.getWorkspace()
34
- const primary = workspace.worktrees?.find((w) => w.source === 'primary')
35
- if (!primary) {
36
- throw new Error(
37
- `workspace "${workspace.name}" has no primary worktree — set --project when creating it`
38
- )
39
- }
40
-
41
- // Check the daemon's capability BEFORE mutating disk — otherwise a
42
- // capability mismatch would leave a git worktree on disk with no
43
- // catalog record to track it.
44
26
  const daemon = await ctx.getDaemon()
45
- if (!daemon.hasCapability(IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS)) {
46
- throw new Error(
47
- 'daemon predates worktreeLifecycleEvents capability — restart aimux to pick up the new daemon'
48
- )
49
- }
50
-
51
- const worktreeId = createPrefixedId('worktree')
52
- const targetPath = makeWorktreePath({
53
- repoRoot: primary.repoRoot,
54
- worktreeId,
55
- worktreeName: name,
56
- })
57
- await ensureAimuxWorktreeRoot()
58
- await assertSafeAimuxWorktreePath(targetPath)
59
-
60
- await createGitWorktree({
61
- baseRef: base,
62
- branchName: branch,
63
- repoPath: primary.repoRoot,
64
- targetPath,
65
- })
66
-
67
- const now = new Date().toISOString()
68
- const record: WorktreeRecord = {
69
- baseRef: base,
70
- branch,
71
- createdAt: now,
72
- createdByAimux: true,
73
- id: worktreeId,
74
- name,
75
- path: targetPath,
76
- repoRoot: primary.repoRoot,
77
- source: 'aimux-temp',
78
- updatedAt: now,
79
- }
80
-
81
- try {
82
- await daemon.expectOk('addWorktreeRecord', { sessionId: workspace.id, worktree: record })
83
- } catch (error) {
84
- // Catalog registration failed — roll back the on-disk worktree so
85
- // `worktree list` doesn't perpetually surface an orphan. Swallow
86
- // rollback errors: report the original failure, which is the real
87
- // problem the operator needs to see.
88
- try {
89
- await removeGitWorktree({
90
- force: true,
91
- repoPath: primary.repoRoot,
92
- targetPath,
93
- })
94
- } catch {
95
- // Best-effort rollback; leave the git-side worktree if it can't be
96
- // removed cleanly. `worktree list --workspace` will flag it as
97
- // `gitTracked: true, catalog: no` on the next inspection.
98
- }
99
- throw error
100
- }
27
+ const record = await createWorkspaceWorktree({ base, branch, daemon, name, workspace })
101
28
 
102
29
  writeJson({
103
- branch,
104
- id: worktreeId,
105
- name,
106
- path: targetPath,
107
- repoRoot: primary.repoRoot,
30
+ branch: record.branch,
31
+ id: record.id,
32
+ name: record.name,
33
+ path: record.path,
34
+ repoRoot: record.repoRoot,
108
35
  })
109
36
  return EXIT_OK
110
37
  },
package/src/cli/flags.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  export interface FlagSpec {
8
8
  name: string
9
- kind: 'string' | 'number' | 'boolean'
9
+ kind: 'string' | 'number' | 'boolean' | 'optional-string'
10
10
  description?: string
11
11
  }
12
12
 
@@ -59,6 +59,13 @@ export function parseArgs(
59
59
  flags[name] = true
60
60
  continue
61
61
  }
62
+ if (spec.kind === 'optional-string') {
63
+ // Value binds ONLY in the `=` form (`--flag=value`). A bare `--flag`
64
+ // must not swallow the next token (it may be a positional), so it
65
+ // parses as boolean `true`.
66
+ flags[name] = eq === -1 ? true : token.slice(eq + 1)
67
+ continue
68
+ }
62
69
  const raw = eq === -1 ? argv[++i] : token.slice(eq + 1)
63
70
  if (raw === undefined) {
64
71
  throw new CliUsageError(`flag --${name} requires a value`)
package/src/cli/index.ts CHANGED
@@ -20,8 +20,8 @@ const EXIT_CODES_BLOCK = [
20
20
  ' 2 usage error (bad flags, unknown command, missing argument)',
21
21
  ' 3 runtime error (server replied with error, command failed)',
22
22
  ' 4 daemon unreachable (socket missing and autostart failed)',
23
- ' 10 question (tab run: worker is blocked on a question/permission)',
24
- ' 124 timeout (tab wait, tab tail --timeout, workspace switch --wait)',
23
+ ' 10 question (tab run / tab await: worker is blocked on a question/permission)',
24
+ ' 124 timeout (tab run, tab await, tab wait, tab tail --timeout, workspace switch --wait)',
25
25
  ].join('\n')
26
26
 
27
27
  const OUTPUT_CONTRACT_BLOCK = [
@@ -52,6 +52,7 @@ function formatArgs(args: readonly ArgSpec[]): string {
52
52
  function flagValueHint(flag: FlagSpec): string {
53
53
  if (flag.kind === 'boolean') return ''
54
54
  if (flag.kind === 'number') return ' <n>'
55
+ if (flag.kind === 'optional-string') return `[=<${flag.name}>]`
55
56
  return ` <${flag.name}>`
56
57
  }
57
58
 
@@ -1,6 +1,7 @@
1
1
  import type { CliContext } from './context'
2
2
  import type { ArgSpec, FlagSpec } from './flags'
3
3
 
4
+ import { tabAwait } from './commands/tab/await'
4
5
  import { tabClose } from './commands/tab/close'
5
6
  import { tabCreate } from './commands/tab/create'
6
7
  import { tabFocus } from './commands/tab/focus'
@@ -33,6 +34,7 @@ export const COMMANDS: readonly CliCommand[] = [
33
34
  tabCreate,
34
35
  tabSend,
35
36
  tabRun,
37
+ tabAwait,
36
38
  tabFocus,
37
39
  tabClose,
38
40
  tabSnapshot,
package/src/config.ts CHANGED
@@ -13,7 +13,14 @@ function migrateThemeId(value: unknown): ThemeId | undefined {
13
13
  return resolveLegacyThemeId(value)
14
14
  }
15
15
 
16
- export const CONFIG_PATH = `${getProfileConfigDir()}/aimux.json`
16
+ /**
17
+ * Path to the active profile's config file, resolved at CALL time. It must not
18
+ * be a module-level constant: `runCli` applies `--profile` (via `AIMUX_PROFILE`)
19
+ * after this module is imported, so a frozen path would read the wrong profile.
20
+ */
21
+ export function getConfigPath(): string {
22
+ return `${getProfileConfigDir()}/aimux.json`
23
+ }
17
24
 
18
25
  export interface PersistedGitPane {
19
26
  diffModeRatio?: number
@@ -238,12 +245,13 @@ function isCustomCommandsRecord(value: unknown): value is Record<string, string>
238
245
  }
239
246
 
240
247
  export function loadConfigResult(): ConfigLoadResult {
248
+ const configPath = getConfigPath()
241
249
  try {
242
- if (!existsSync(CONFIG_PATH)) {
250
+ if (!existsSync(configPath)) {
243
251
  return { config: DEFAULT_CONFIG, issues: [], source: 'defaults' }
244
252
  }
245
253
 
246
- const raw = readFileSync(CONFIG_PATH, 'utf8')
254
+ const raw = readFileSync(configPath, 'utf8')
247
255
  const parsed = JSON.parse(raw) as {
248
256
  version?: number
249
257
  customCommands?: unknown
@@ -346,7 +354,7 @@ export function loadConfigResult(): ConfigLoadResult {
346
354
  const validWorktreeTemplates = parseWorktreeTemplates(parsed.worktreeTemplates, issues)
347
355
 
348
356
  if (issues.length > 0) {
349
- logDebug('config.load.validationIssue', { issues, path: CONFIG_PATH })
357
+ logDebug('config.load.validationIssue', { issues, path: configPath })
350
358
  }
351
359
 
352
360
  return {
@@ -371,7 +379,7 @@ export function loadConfigResult(): ConfigLoadResult {
371
379
  }
372
380
  } catch (error) {
373
381
  const message = error instanceof Error ? error.message : String(error)
374
- logDebug('config.load.error', { error: message, path: CONFIG_PATH })
382
+ logDebug('config.load.error', { error: message, path: configPath })
375
383
  return {
376
384
  config: DEFAULT_CONFIG,
377
385
  issues: [`failed to load config: ${message}`],
@@ -385,14 +393,15 @@ export function loadConfig(): AimuxConfig {
385
393
  }
386
394
 
387
395
  export function saveConfig(config: AimuxConfig): boolean {
396
+ const configPath = getConfigPath()
388
397
  try {
389
398
  mkdirSync(getProfileConfigDir(), { recursive: true })
390
- writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`)
399
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
391
400
  return true
392
401
  } catch (error) {
393
402
  logDebug('config.save.error', {
394
403
  error: error instanceof Error ? error.message : String(error),
395
- path: CONFIG_PATH,
404
+ path: configPath,
396
405
  })
397
406
  return false
398
407
  }
package/src/doctor.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
 
3
- import { CONFIG_PATH, loadConfigResult } from './config'
3
+ import { getConfigPath, loadConfigResult } from './config'
4
4
  import { ASSISTANT_OPTIONS, isCommandAvailable, parseCommand } from './pty/command-registry'
5
5
 
6
6
  export interface DoctorCheck {
@@ -22,11 +22,12 @@ function getConfigDetails(configResult: ReturnType<typeof loadConfigResult>): st
22
22
  return configResult.issues.join('; ')
23
23
  }
24
24
 
25
- if (existsSync(CONFIG_PATH)) {
26
- return `loaded ${CONFIG_PATH}`
25
+ const configPath = getConfigPath()
26
+ if (existsSync(configPath)) {
27
+ return `loaded ${configPath}`
27
28
  }
28
29
 
29
- return `using defaults (${CONFIG_PATH} not found)`
30
+ return `using defaults (${configPath} not found)`
30
31
  }
31
32
 
32
33
  function getAssistantDetails(
@@ -0,0 +1,8 @@
1
+ // Git plumbing and Bun's file API both hand a blob back as a single JS string. Past
2
+ // JavaScriptCore's string cap the allocation does not throw — it aborts the process
3
+ // with SIGTRAP, which no try/catch can intercept and which takes the whole TUI down
4
+ // with it. Every read of file content in the git layer is gated on this.
5
+ //
6
+ // 5 MB covers source files and mid-sized CSVs. Anything past it is a build artefact
7
+ // or a download, and nobody reviews those line-by-line.
8
+ export const MAX_DIFF_BYTES = 5 * 1024 * 1024