tmux-ide 2.9.3 → 2.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.3",
4
- "description": "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
3
+ "version": "2.9.4",
4
+ "description": "The open-source workspace for coding agents: run Claude Code, Codex and other agents in tmux with live status",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "tmux-ide": "bin/cli.js"
@@ -72,6 +72,8 @@
72
72
  "postinstall": "node scripts/postinstall.js",
73
73
  "docs": "turbo run dev --filter=@tmux-ide/docs",
74
74
  "demo:tui": "bun --preload @opentui/solid/preload docs/scripts/render-tui-demo.tsx",
75
+ "demo:font": "node docs/scripts/tui-demo-font/build.mjs",
76
+ "readme:media": "node docs/scripts/readme-media.mjs",
75
77
  "test:tui-renderer": "bun test --preload @opentui/solid/preload --preload ./packages/daemon/test-support/opentui-renderer-preload.ts ./packages/daemon/src/tui/mirror/automatic-contrast-renderer.test.tsx ./packages/daemon/src/tui/mirror/pane-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/native-grid-projection-renderer.test.tsx ./packages/daemon/src/tui/mirror/widget-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/missions-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/recipes-gallery-renderer.test.tsx ./packages/daemon/src/tui/mirror/shell-chrome-renderer.test.tsx ./packages/daemon/src/tui/mirror/sidebar-renderer.test.tsx ./packages/daemon/src/tui/mirror/home-files-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/changes-terminal-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/activity-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/features/files/session-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-terminal-workspace-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-shell-view-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-root-error-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-backpressure-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-demand-cadence-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/files-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/changes-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/missions-activity-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/dialogs-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/optional-feature-registry-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/palette-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/features/rich-preview/feature.test.ts ./packages/daemon/src/tui/mirror/runtime/rich-preview-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/application-shell-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/pane-frame-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/terminal-pane-chrome-view.test.tsx ./packages/daemon/src/tui/mirror/workspace/terminal-window-strip-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/workbench-shell-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/workbench-dock-dual-host-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/agent-terminal-canvas-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/command-palette-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/opentui-insertion-stability-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-shell-home-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/terminal-pane-header-polish-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-home-agent-roster-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-home-agent-flow-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/guided-tour-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-shell-sidebar-catalog-renderer.test.tsx ./packages/daemon/src/tui/mirror/ui/ui-renderer.test.tsx ./packages/daemon/src/tui/mirror/ui/pane-interaction-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-machine-sidebar-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-add-machine-dialog-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-new-agent-dialog-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-fleet-switcher-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-palette-preview-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-fleet-palette-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-fleet-session-actions-renderer.test.tsx ./packages/daemon/src/tui/mirror/ui/agent-team-renderer.test.tsx ./scripts/solid-conditional-props.test.tsx",
76
78
  "test:tui-smoke": "bun scripts/smoke-tui-missions.mjs",
77
79
  "test:tui-live": "node scripts/tui-testdrive.mjs smoke",
@@ -112,7 +114,11 @@
112
114
  },
113
115
  "keywords": [
114
116
  "tmux",
115
- "ide",
117
+ "coding-agents",
118
+ "claude-code",
119
+ "codex",
120
+ "ai-agents",
121
+ "agent-teams",
116
122
  "terminal",
117
123
  "workspace",
118
124
  "developer-tools"
@@ -124,7 +130,7 @@
124
130
  "type": "git",
125
131
  "url": "git+https://github.com/wavyrai/tmux-ide.git"
126
132
  },
127
- "homepage": "https://github.com/wavyrai/tmux-ide#readme",
133
+ "homepage": "https://tmux-ide.com",
128
134
  "bugs": {
129
135
  "url": "https://github.com/wavyrai/tmux-ide/issues"
130
136
  },
@@ -21,9 +21,9 @@
21
21
  <key>CFBundlePackageType</key>
22
22
  <string>APPL</string>
23
23
  <key>CFBundleShortVersionString</key>
24
- <string>2.9.3</string>
24
+ <string>2.9.4</string>
25
25
  <key>CFBundleVersion</key>
26
- <string>2.9.3</string>
26
+ <string>2.9.4</string>
27
27
  <key>LSMinimumSystemVersion</key>
28
28
  <string>11.0</string>
29
29
  <key>LSUIElement</key>
@@ -10,7 +10,7 @@
10
10
  </data>
11
11
  <key>Resources/Assets.car</key>
12
12
  <data>
13
- Z5KHrNHvuP0dz4KeTalLvP3ooGE=
13
+ TPHWDo+ga7DgPtltNVjNyW6+Kpc=
14
14
  </data>
15
15
  </dict>
16
16
  <key>files2</key>
@@ -26,7 +26,7 @@
26
26
  <dict>
27
27
  <key>hash2</key>
28
28
  <data>
29
- tjo0L6QuhP9jDVHACgzDgf4ZWE5eMPM/btcI9elmdEQ=
29
+ I1uLFZ9MLgryptxKSUR8tbfEKr5vQzHGgIpna3gvlxc=
30
30
  </data>
31
31
  </dict>
32
32
  </dict>
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Claude Code agent teams — opt-out enablement in the user's Claude settings.
3
+ *
4
+ * Agent teams let one Claude Code session spawn teammates; with
5
+ * `teammateMode: "auto"` Claude puts them in split panes when it runs inside
6
+ * tmux (in-process elsewhere), which is exactly what tmux-ide shows and
7
+ * routes. The installer enables this by default and `tmux-ide integration
8
+ * agent-teams enable|disable|status` manages it explicitly.
9
+ *
10
+ * Rules (pure, in {@link mergeAgentTeams}):
11
+ * - `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` is set to "1" only when absent.
12
+ * An explicit off value ("0", "false", false, 0…) is the user's opt-out: the
13
+ * installer never overrides it and touches nothing else. Only an explicit
14
+ * `tmux-ide integration agent-teams enable` (overrideOptOut) turns it back on.
15
+ * - `teammateMode: "auto"` is set only when `teammateMode` is absent.
16
+ * - Every other key is preserved. Nothing changed → nothing written.
17
+ *
18
+ * Writes (io) reuse the hooks integration's settings path resolution, including
19
+ * the `TMUX_IDE_CLAUDE_SETTINGS` override: one-time timestamped backup before
20
+ * the first modification, then an atomic temp-file + rename. Invalid JSON is
21
+ * reported and never rewritten.
22
+ */
23
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, copyFileSync, unlinkSync, writeFileSync, accessSync, constants, } from "node:fs";
24
+ import { basename, delimiter, dirname, join } from "node:path";
25
+ import { resolveRuntimeNamespace, runtimeOwnedPath } from "../../lib/runtime-namespace.js";
26
+ export const AGENT_TEAMS_ENV = "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS";
27
+ export const DEFAULT_TEAMMATE_MODE = "auto";
28
+ const BACKUP_INFIX = ".tmux-ide-backup-";
29
+ /** PURE — an explicit off value is the user's opt-out. */
30
+ export function isOptOut(value) {
31
+ if (value === false || value === 0)
32
+ return true;
33
+ return typeof value === "string" && /^\s*(0|false|off|no)\s*$/iu.test(value);
34
+ }
35
+ function envOf(settings) {
36
+ const env = settings.env;
37
+ if (env === undefined)
38
+ return undefined;
39
+ if (!env || typeof env !== "object" || Array.isArray(env))
40
+ throw new Error("settings env must be an object");
41
+ return env;
42
+ }
43
+ /** PURE — how the settings currently stand for agent teams. */
44
+ export function agentTeamsSetting(settings) {
45
+ const value = envOf(settings)?.[AGENT_TEAMS_ENV];
46
+ if (value === undefined)
47
+ return "not-configured";
48
+ return isOptOut(value) ? "disabled-by-user" : "enabled";
49
+ }
50
+ /**
51
+ * PURE — enable agent teams without overriding anything the user chose, unless
52
+ * `overrideOptOut` (the user explicitly asked to enable) replaces their "0".
53
+ */
54
+ export function mergeAgentTeams(settings, { overrideOptOut = false } = {}) {
55
+ const setting = agentTeamsSetting(settings);
56
+ if (setting === "disabled-by-user" && !overrideOptOut)
57
+ return { settings, changes: [], setting };
58
+ const changes = [];
59
+ let next = settings;
60
+ if (setting !== "enabled") {
61
+ next = { ...next, env: { ...(envOf(settings) ?? {}), [AGENT_TEAMS_ENV]: "1" } };
62
+ changes.push(`env.${AGENT_TEAMS_ENV}=1`);
63
+ }
64
+ if (!("teammateMode" in settings)) {
65
+ next = { ...next, teammateMode: DEFAULT_TEAMMATE_MODE };
66
+ changes.push(`teammateMode=${DEFAULT_TEAMMATE_MODE}`);
67
+ }
68
+ return { settings: next, changes, setting: "enabled" };
69
+ }
70
+ /** PURE — record an explicit opt-out ("0"), which later enables respect. */
71
+ export function disableAgentTeams(settings) {
72
+ if (agentTeamsSetting(settings) === "disabled-by-user")
73
+ return { settings, changes: [], setting: "disabled-by-user" };
74
+ return {
75
+ settings: { ...settings, env: { ...(envOf(settings) ?? {}), [AGENT_TEAMS_ENV]: "0" } },
76
+ changes: [`env.${AGENT_TEAMS_ENV}=0`],
77
+ setting: "disabled-by-user",
78
+ };
79
+ }
80
+ export function agentTeamsPaths() {
81
+ const namespace = resolveRuntimeNamespace();
82
+ return { settingsPath: namespace.claudeSettingsPath, claudeDir: namespace.claudeDir };
83
+ }
84
+ /** `claude` on PATH, without spawning anything. */
85
+ export function claudeOnPath(path = process.env.PATH ?? "") {
86
+ return path
87
+ .split(delimiter)
88
+ .filter(Boolean)
89
+ .some((dir) => {
90
+ try {
91
+ accessSync(join(dir, "claude"), constants.X_OK);
92
+ return statSync(join(dir, "claude")).isFile();
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ });
98
+ }
99
+ export class InvalidClaudeSettingsError extends Error {
100
+ }
101
+ function readSettings(path) {
102
+ if (!existsSync(path))
103
+ return { settings: {}, exists: false };
104
+ try {
105
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
106
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
107
+ throw new Error("not an object");
108
+ envOf(parsed);
109
+ return { settings: parsed, exists: true };
110
+ }
111
+ catch {
112
+ throw new InvalidClaudeSettingsError(`${path} is not valid settings JSON — fix or move it, then retry`);
113
+ }
114
+ }
115
+ /** Existing one-time backup, if tmux-ide already made one for this file. */
116
+ function existingBackup(settingsPath) {
117
+ const dir = dirname(settingsPath);
118
+ const prefix = `${basename(settingsPath)}${BACKUP_INFIX}`;
119
+ try {
120
+ const found = readdirSync(dir).find((name) => name.startsWith(prefix));
121
+ return found ? join(dir, found) : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ function writeSettings(path, settings, exists) {
128
+ mkdirSync(dirname(path), { recursive: true });
129
+ let backup = null;
130
+ if (exists) {
131
+ backup = existingBackup(path);
132
+ if (!backup) {
133
+ const stamp = new Date()
134
+ .toISOString()
135
+ .replace(/[-:]/gu, "")
136
+ .replace(/\.\d+Z$/u, "Z");
137
+ backup = `${path}${BACKUP_INFIX}${stamp}`;
138
+ copyFileSync(path, backup);
139
+ }
140
+ }
141
+ const temporary = join(dirname(path), `.${basename(path)}.tmux-ide-${process.pid}.tmp`);
142
+ try {
143
+ writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
144
+ if (exists)
145
+ chmodSync(temporary, statSync(path).mode & 0o777);
146
+ renameSync(temporary, path);
147
+ }
148
+ catch (error) {
149
+ try {
150
+ unlinkSync(temporary);
151
+ }
152
+ catch {
153
+ /* already renamed or never written */
154
+ }
155
+ throw error;
156
+ }
157
+ return backup;
158
+ }
159
+ function detected(paths, onPath) {
160
+ return existsSync(paths.claudeDir) || onPath();
161
+ }
162
+ function guarded(paths) {
163
+ if (!paths && resolveRuntimeNamespace().development)
164
+ throw new Error("Development agent-teams changes require explicit fixture paths");
165
+ const resolved = paths ?? agentTeamsPaths();
166
+ runtimeOwnedPath(resolved.settingsPath);
167
+ return resolved;
168
+ }
169
+ /** Enable agent teams when Claude Code is installed and the user hasn't opted out. */
170
+ export function enableClaudeAgentTeams(paths, onPath = () => claudeOnPath(), { overrideOptOut = false } = {}) {
171
+ const resolved = guarded(paths);
172
+ const { settingsPath } = resolved;
173
+ const base = { settingsPath, changes: [], backup: null };
174
+ if (!detected(resolved, onPath))
175
+ return {
176
+ ...base,
177
+ action: "skipped",
178
+ claudeDetected: false,
179
+ message: `Claude Code not found (no ${resolved.claudeDir}, no claude on PATH); agent teams not configured.`,
180
+ };
181
+ let read;
182
+ try {
183
+ read = readSettings(settingsPath);
184
+ }
185
+ catch (error) {
186
+ return { ...base, action: "invalid", claudeDetected: true, message: error.message };
187
+ }
188
+ const merged = mergeAgentTeams(read.settings, { overrideOptOut });
189
+ if (merged.setting === "disabled-by-user")
190
+ return {
191
+ ...base,
192
+ action: "disabled-by-user",
193
+ claudeDetected: true,
194
+ message: `Claude Code agent teams are disabled by user in ${settingsPath} (${AGENT_TEAMS_ENV}); ` +
195
+ "left unchanged. Enable explicitly: tmux-ide integration agent-teams enable",
196
+ };
197
+ if (merged.changes.length === 0)
198
+ return {
199
+ ...base,
200
+ action: "unchanged",
201
+ claudeDetected: true,
202
+ message: `Claude Code agent teams already enabled in ${settingsPath}.`,
203
+ };
204
+ const backup = writeSettings(settingsPath, merged.settings, read.exists);
205
+ return {
206
+ settingsPath,
207
+ changes: merged.changes,
208
+ backup,
209
+ action: "enabled",
210
+ claudeDetected: true,
211
+ message: `Enabled Claude Code agent teams in ${settingsPath} (${merged.changes.join(", ")}` +
212
+ `${backup ? `; backup: ${backup}` : ""}). Disable: tmux-ide integration agent-teams disable`,
213
+ };
214
+ }
215
+ /** Record the user's opt-out so neither the installer nor `enable` turns it back on silently. */
216
+ export function disableClaudeAgentTeams(paths) {
217
+ const resolved = guarded(paths);
218
+ const { settingsPath } = resolved;
219
+ const base = { settingsPath, changes: [], backup: null, claudeDetected: true };
220
+ let read;
221
+ try {
222
+ read = readSettings(settingsPath);
223
+ }
224
+ catch (error) {
225
+ return { ...base, action: "invalid", message: error.message };
226
+ }
227
+ const next = disableAgentTeams(read.settings);
228
+ if (next.changes.length === 0)
229
+ return {
230
+ ...base,
231
+ action: "unchanged",
232
+ message: `Claude Code agent teams already disabled in ${settingsPath}.`,
233
+ };
234
+ const backup = writeSettings(settingsPath, next.settings, read.exists);
235
+ return {
236
+ ...base,
237
+ changes: next.changes,
238
+ backup,
239
+ action: "disabled",
240
+ message: `Disabled Claude Code agent teams in ${settingsPath} (${next.changes.join(", ")}` +
241
+ `${backup ? `; backup: ${backup}` : ""}). Re-enable: tmux-ide integration agent-teams enable`,
242
+ };
243
+ }
244
+ export function claudeAgentTeamsStatus(paths = agentTeamsPaths(), onPath = () => claudeOnPath()) {
245
+ const claudeDetected = detected(paths, onPath);
246
+ try {
247
+ const { settings } = readSettings(paths.settingsPath);
248
+ const mode = settings.teammateMode;
249
+ return {
250
+ settingsPath: paths.settingsPath,
251
+ claudeDetected,
252
+ setting: agentTeamsSetting(settings),
253
+ teammateMode: typeof mode === "string" ? mode : null,
254
+ };
255
+ }
256
+ catch {
257
+ return {
258
+ settingsPath: paths.settingsPath,
259
+ claudeDetected,
260
+ setting: "invalid-settings",
261
+ teammateMode: null,
262
+ };
263
+ }
264
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Claude Code agent teams — opt-out enablement in the user's Claude settings.
3
+ *
4
+ * Agent teams let one Claude Code session spawn teammates; with
5
+ * `teammateMode: "auto"` Claude puts them in split panes when it runs inside
6
+ * tmux (in-process elsewhere), which is exactly what tmux-ide shows and
7
+ * routes. The installer enables this by default and `tmux-ide integration
8
+ * agent-teams enable|disable|status` manages it explicitly.
9
+ *
10
+ * Rules (pure, in {@link mergeAgentTeams}):
11
+ * - `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` is set to "1" only when absent.
12
+ * An explicit off value ("0", "false", false, 0…) is the user's opt-out: the
13
+ * installer never overrides it and touches nothing else. Only an explicit
14
+ * `tmux-ide integration agent-teams enable` (overrideOptOut) turns it back on.
15
+ * - `teammateMode: "auto"` is set only when `teammateMode` is absent.
16
+ * - Every other key is preserved. Nothing changed → nothing written.
17
+ *
18
+ * Writes (io) reuse the hooks integration's settings path resolution, including
19
+ * the `TMUX_IDE_CLAUDE_SETTINGS` override: one-time timestamped backup before
20
+ * the first modification, then an atomic temp-file + rename. Invalid JSON is
21
+ * reported and never rewritten.
22
+ */
23
+ import {
24
+ chmodSync,
25
+ existsSync,
26
+ mkdirSync,
27
+ readFileSync,
28
+ readdirSync,
29
+ renameSync,
30
+ statSync,
31
+ copyFileSync,
32
+ unlinkSync,
33
+ writeFileSync,
34
+ accessSync,
35
+ constants,
36
+ } from "node:fs";
37
+ import { basename, delimiter, dirname, join } from "node:path";
38
+ import { resolveRuntimeNamespace, runtimeOwnedPath } from "../../lib/runtime-namespace.ts";
39
+
40
+ export const AGENT_TEAMS_ENV = "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS";
41
+ export const DEFAULT_TEAMMATE_MODE = "auto";
42
+ const BACKUP_INFIX = ".tmux-ide-backup-";
43
+
44
+ type Settings = Record<string, unknown>;
45
+
46
+ export type AgentTeamsSetting = "enabled" | "disabled-by-user" | "not-configured";
47
+
48
+ /** PURE — an explicit off value is the user's opt-out. */
49
+ export function isOptOut(value: unknown): boolean {
50
+ if (value === false || value === 0) return true;
51
+ return typeof value === "string" && /^\s*(0|false|off|no)\s*$/iu.test(value);
52
+ }
53
+
54
+ function envOf(settings: Settings): Record<string, unknown> | undefined {
55
+ const env = settings.env;
56
+ if (env === undefined) return undefined;
57
+ if (!env || typeof env !== "object" || Array.isArray(env))
58
+ throw new Error("settings env must be an object");
59
+ return env as Record<string, unknown>;
60
+ }
61
+
62
+ /** PURE — how the settings currently stand for agent teams. */
63
+ export function agentTeamsSetting(settings: Settings): AgentTeamsSetting {
64
+ const value = envOf(settings)?.[AGENT_TEAMS_ENV];
65
+ if (value === undefined) return "not-configured";
66
+ return isOptOut(value) ? "disabled-by-user" : "enabled";
67
+ }
68
+
69
+ export interface AgentTeamsMerge {
70
+ readonly settings: Settings;
71
+ /** Human-readable `key=value` list of what was set; empty when unchanged. */
72
+ readonly changes: readonly string[];
73
+ readonly setting: AgentTeamsSetting;
74
+ }
75
+
76
+ /**
77
+ * PURE — enable agent teams without overriding anything the user chose, unless
78
+ * `overrideOptOut` (the user explicitly asked to enable) replaces their "0".
79
+ */
80
+ export function mergeAgentTeams(
81
+ settings: Settings,
82
+ { overrideOptOut = false }: { readonly overrideOptOut?: boolean } = {},
83
+ ): AgentTeamsMerge {
84
+ const setting = agentTeamsSetting(settings);
85
+ if (setting === "disabled-by-user" && !overrideOptOut) return { settings, changes: [], setting };
86
+ const changes: string[] = [];
87
+ let next: Settings = settings;
88
+ if (setting !== "enabled") {
89
+ next = { ...next, env: { ...(envOf(settings) ?? {}), [AGENT_TEAMS_ENV]: "1" } };
90
+ changes.push(`env.${AGENT_TEAMS_ENV}=1`);
91
+ }
92
+ if (!("teammateMode" in settings)) {
93
+ next = { ...next, teammateMode: DEFAULT_TEAMMATE_MODE };
94
+ changes.push(`teammateMode=${DEFAULT_TEAMMATE_MODE}`);
95
+ }
96
+ return { settings: next, changes, setting: "enabled" };
97
+ }
98
+
99
+ /** PURE — record an explicit opt-out ("0"), which later enables respect. */
100
+ export function disableAgentTeams(settings: Settings): AgentTeamsMerge {
101
+ if (agentTeamsSetting(settings) === "disabled-by-user")
102
+ return { settings, changes: [], setting: "disabled-by-user" };
103
+ return {
104
+ settings: { ...settings, env: { ...(envOf(settings) ?? {}), [AGENT_TEAMS_ENV]: "0" } },
105
+ changes: [`env.${AGENT_TEAMS_ENV}=0`],
106
+ setting: "disabled-by-user",
107
+ };
108
+ }
109
+
110
+ export interface AgentTeamsPaths {
111
+ /** Claude Code's user settings file. */
112
+ readonly settingsPath: string;
113
+ /** Claude Code's home (~/.claude); its presence means Claude is installed. */
114
+ readonly claudeDir: string;
115
+ }
116
+
117
+ export function agentTeamsPaths(): AgentTeamsPaths {
118
+ const namespace = resolveRuntimeNamespace();
119
+ return { settingsPath: namespace.claudeSettingsPath, claudeDir: namespace.claudeDir };
120
+ }
121
+
122
+ /** `claude` on PATH, without spawning anything. */
123
+ export function claudeOnPath(path = process.env.PATH ?? ""): boolean {
124
+ return path
125
+ .split(delimiter)
126
+ .filter(Boolean)
127
+ .some((dir) => {
128
+ try {
129
+ accessSync(join(dir, "claude"), constants.X_OK);
130
+ return statSync(join(dir, "claude")).isFile();
131
+ } catch {
132
+ return false;
133
+ }
134
+ });
135
+ }
136
+
137
+ export class InvalidClaudeSettingsError extends Error {}
138
+
139
+ function readSettings(path: string): { settings: Settings; exists: boolean } {
140
+ if (!existsSync(path)) return { settings: {}, exists: false };
141
+ try {
142
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
143
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
144
+ throw new Error("not an object");
145
+ envOf(parsed as Settings);
146
+ return { settings: parsed as Settings, exists: true };
147
+ } catch {
148
+ throw new InvalidClaudeSettingsError(
149
+ `${path} is not valid settings JSON — fix or move it, then retry`,
150
+ );
151
+ }
152
+ }
153
+
154
+ /** Existing one-time backup, if tmux-ide already made one for this file. */
155
+ function existingBackup(settingsPath: string): string | null {
156
+ const dir = dirname(settingsPath);
157
+ const prefix = `${basename(settingsPath)}${BACKUP_INFIX}`;
158
+ try {
159
+ const found = readdirSync(dir).find((name) => name.startsWith(prefix));
160
+ return found ? join(dir, found) : null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function writeSettings(path: string, settings: Settings, exists: boolean): string | null {
167
+ mkdirSync(dirname(path), { recursive: true });
168
+ let backup: string | null = null;
169
+ if (exists) {
170
+ backup = existingBackup(path);
171
+ if (!backup) {
172
+ const stamp = new Date()
173
+ .toISOString()
174
+ .replace(/[-:]/gu, "")
175
+ .replace(/\.\d+Z$/u, "Z");
176
+ backup = `${path}${BACKUP_INFIX}${stamp}`;
177
+ copyFileSync(path, backup);
178
+ }
179
+ }
180
+ const temporary = join(dirname(path), `.${basename(path)}.tmux-ide-${process.pid}.tmp`);
181
+ try {
182
+ writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
183
+ if (exists) chmodSync(temporary, statSync(path).mode & 0o777);
184
+ renameSync(temporary, path);
185
+ } catch (error) {
186
+ try {
187
+ unlinkSync(temporary);
188
+ } catch {
189
+ /* already renamed or never written */
190
+ }
191
+ throw error;
192
+ }
193
+ return backup;
194
+ }
195
+
196
+ export interface AgentTeamsResult {
197
+ readonly action:
198
+ | "enabled"
199
+ | "disabled"
200
+ | "unchanged"
201
+ | "disabled-by-user"
202
+ | "skipped"
203
+ | "invalid";
204
+ readonly settingsPath: string;
205
+ readonly changes: readonly string[];
206
+ readonly backup: string | null;
207
+ readonly claudeDetected: boolean;
208
+ readonly message: string;
209
+ }
210
+
211
+ function detected(paths: AgentTeamsPaths, onPath: () => boolean): boolean {
212
+ return existsSync(paths.claudeDir) || onPath();
213
+ }
214
+
215
+ function guarded(paths: AgentTeamsPaths | undefined): AgentTeamsPaths {
216
+ if (!paths && resolveRuntimeNamespace().development)
217
+ throw new Error("Development agent-teams changes require explicit fixture paths");
218
+ const resolved = paths ?? agentTeamsPaths();
219
+ runtimeOwnedPath(resolved.settingsPath);
220
+ return resolved;
221
+ }
222
+
223
+ /** Enable agent teams when Claude Code is installed and the user hasn't opted out. */
224
+ export function enableClaudeAgentTeams(
225
+ paths?: AgentTeamsPaths,
226
+ onPath: () => boolean = () => claudeOnPath(),
227
+ { overrideOptOut = false }: { readonly overrideOptOut?: boolean } = {},
228
+ ): AgentTeamsResult {
229
+ const resolved = guarded(paths);
230
+ const { settingsPath } = resolved;
231
+ const base = { settingsPath, changes: [], backup: null } as const;
232
+ if (!detected(resolved, onPath))
233
+ return {
234
+ ...base,
235
+ action: "skipped",
236
+ claudeDetected: false,
237
+ message: `Claude Code not found (no ${resolved.claudeDir}, no claude on PATH); agent teams not configured.`,
238
+ };
239
+ let read: ReturnType<typeof readSettings>;
240
+ try {
241
+ read = readSettings(settingsPath);
242
+ } catch (error) {
243
+ return { ...base, action: "invalid", claudeDetected: true, message: (error as Error).message };
244
+ }
245
+ const merged = mergeAgentTeams(read.settings, { overrideOptOut });
246
+ if (merged.setting === "disabled-by-user")
247
+ return {
248
+ ...base,
249
+ action: "disabled-by-user",
250
+ claudeDetected: true,
251
+ message:
252
+ `Claude Code agent teams are disabled by user in ${settingsPath} (${AGENT_TEAMS_ENV}); ` +
253
+ "left unchanged. Enable explicitly: tmux-ide integration agent-teams enable",
254
+ };
255
+ if (merged.changes.length === 0)
256
+ return {
257
+ ...base,
258
+ action: "unchanged",
259
+ claudeDetected: true,
260
+ message: `Claude Code agent teams already enabled in ${settingsPath}.`,
261
+ };
262
+ const backup = writeSettings(settingsPath, merged.settings, read.exists);
263
+ return {
264
+ settingsPath,
265
+ changes: merged.changes,
266
+ backup,
267
+ action: "enabled",
268
+ claudeDetected: true,
269
+ message:
270
+ `Enabled Claude Code agent teams in ${settingsPath} (${merged.changes.join(", ")}` +
271
+ `${backup ? `; backup: ${backup}` : ""}). Disable: tmux-ide integration agent-teams disable`,
272
+ };
273
+ }
274
+
275
+ /** Record the user's opt-out so neither the installer nor `enable` turns it back on silently. */
276
+ export function disableClaudeAgentTeams(paths?: AgentTeamsPaths): AgentTeamsResult {
277
+ const resolved = guarded(paths);
278
+ const { settingsPath } = resolved;
279
+ const base = { settingsPath, changes: [], backup: null, claudeDetected: true } as const;
280
+ let read: ReturnType<typeof readSettings>;
281
+ try {
282
+ read = readSettings(settingsPath);
283
+ } catch (error) {
284
+ return { ...base, action: "invalid", message: (error as Error).message };
285
+ }
286
+ const next = disableAgentTeams(read.settings);
287
+ if (next.changes.length === 0)
288
+ return {
289
+ ...base,
290
+ action: "unchanged",
291
+ message: `Claude Code agent teams already disabled in ${settingsPath}.`,
292
+ };
293
+ const backup = writeSettings(settingsPath, next.settings, read.exists);
294
+ return {
295
+ ...base,
296
+ changes: next.changes,
297
+ backup,
298
+ action: "disabled",
299
+ message:
300
+ `Disabled Claude Code agent teams in ${settingsPath} (${next.changes.join(", ")}` +
301
+ `${backup ? `; backup: ${backup}` : ""}). Re-enable: tmux-ide integration agent-teams enable`,
302
+ };
303
+ }
304
+
305
+ export interface AgentTeamsStatus {
306
+ readonly settingsPath: string;
307
+ readonly claudeDetected: boolean;
308
+ readonly setting: AgentTeamsSetting | "invalid-settings";
309
+ readonly teammateMode: string | null;
310
+ }
311
+
312
+ export function claudeAgentTeamsStatus(
313
+ paths: AgentTeamsPaths = agentTeamsPaths(),
314
+ onPath: () => boolean = () => claudeOnPath(),
315
+ ): AgentTeamsStatus {
316
+ const claudeDetected = detected(paths, onPath);
317
+ try {
318
+ const { settings } = readSettings(paths.settingsPath);
319
+ const mode = settings.teammateMode;
320
+ return {
321
+ settingsPath: paths.settingsPath,
322
+ claudeDetected,
323
+ setting: agentTeamsSetting(settings),
324
+ teammateMode: typeof mode === "string" ? mode : null,
325
+ };
326
+ } catch {
327
+ return {
328
+ settingsPath: paths.settingsPath,
329
+ claudeDetected,
330
+ setting: "invalid-settings",
331
+ teammateMode: null,
332
+ };
333
+ }
334
+ }