@bytespell/amux 0.0.12 → 0.0.15

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.
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ isToolCallUpdate,
4
+ isToolCallUpdateMessage,
5
+ normalizeSessionUpdate,
6
+ } from './session-updates.js';
7
+ import type * as acp from '@agentclientprotocol/sdk';
8
+
9
+ describe('isToolCallUpdate', () => {
10
+ it('returns true for tool_call updates', () => {
11
+ const update = {
12
+ sessionUpdate: 'tool_call',
13
+ id: 'test',
14
+ title: 'Test',
15
+ status: 'pending',
16
+ } as acp.SessionUpdate;
17
+ expect(isToolCallUpdate(update)).toBe(true);
18
+ });
19
+
20
+ it('returns false for other update types', () => {
21
+ const update = {
22
+ sessionUpdate: 'agent_message_chunk',
23
+ content: { type: 'text', text: 'hello' },
24
+ } as acp.SessionUpdate;
25
+ expect(isToolCallUpdate(update)).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe('isToolCallUpdateMessage', () => {
30
+ it('returns true for tool_call_update updates', () => {
31
+ const update = {
32
+ sessionUpdate: 'tool_call_update',
33
+ id: 'test',
34
+ } as acp.SessionUpdate;
35
+ expect(isToolCallUpdateMessage(update)).toBe(true);
36
+ });
37
+
38
+ it('returns false for other update types', () => {
39
+ const update = {
40
+ sessionUpdate: 'tool_call',
41
+ id: 'test',
42
+ title: 'Test',
43
+ status: 'pending',
44
+ } as acp.SessionUpdate;
45
+ expect(isToolCallUpdateMessage(update)).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe('normalizeSessionUpdate', () => {
50
+ it('passes through non-tool updates unchanged', () => {
51
+ const update: acp.SessionUpdate = {
52
+ sessionUpdate: 'agent_message_chunk',
53
+ content: { type: 'text', text: 'hello' },
54
+ };
55
+ const result = normalizeSessionUpdate(update);
56
+ expect(result).toEqual(update);
57
+ });
58
+
59
+ it('passes through turn_start unchanged', () => {
60
+ const update: acp.SessionUpdate = {
61
+ sessionUpdate: 'turn_start',
62
+ };
63
+ const result = normalizeSessionUpdate(update);
64
+ expect(result).toEqual(update);
65
+ });
66
+
67
+ it('passes through turn_end unchanged', () => {
68
+ const update: acp.SessionUpdate = {
69
+ sessionUpdate: 'turn_end',
70
+ };
71
+ const result = normalizeSessionUpdate(update);
72
+ expect(result).toEqual(update);
73
+ });
74
+
75
+ it('passes through tool_call without content unchanged', () => {
76
+ const update = {
77
+ sessionUpdate: 'tool_call',
78
+ id: 'test',
79
+ title: 'Test Tool',
80
+ status: 'pending',
81
+ } as acp.SessionUpdate;
82
+ const result = normalizeSessionUpdate(update);
83
+ expect(result).toEqual(update);
84
+ });
85
+
86
+ it('passes through tool_call with non-diff content unchanged', () => {
87
+ const update = {
88
+ sessionUpdate: 'tool_call',
89
+ id: 'test',
90
+ title: 'Test Tool',
91
+ status: 'pending',
92
+ content: [{ type: 'text', text: 'some output' }],
93
+ } as acp.SessionUpdate;
94
+ const result = normalizeSessionUpdate(update);
95
+ expect(result).toEqual(update);
96
+ });
97
+
98
+ it('normalizes tool_call with diff content', () => {
99
+ const update = {
100
+ sessionUpdate: 'tool_call',
101
+ id: 'test',
102
+ title: 'Edit File',
103
+ status: 'completed',
104
+ content: [
105
+ {
106
+ type: 'diff',
107
+ oldText: 'old content',
108
+ newText: 'new content',
109
+ path: '/path/to/file.ts',
110
+ },
111
+ ],
112
+ } as acp.SessionUpdate;
113
+
114
+ const result = normalizeSessionUpdate(update);
115
+
116
+ // Should have same structure but with normalized diff
117
+ expect(result.sessionUpdate).toBe('tool_call');
118
+ expect((result as { content?: acp.ToolCallContent[] }).content).toHaveLength(1);
119
+
120
+ const diffContent = (result as { content: acp.ToolCallContent[] }).content[0];
121
+ expect(diffContent.type).toBe('diff');
122
+
123
+ // The newText should now contain unified diff format
124
+ const normalizedDiff = diffContent as { newText: string; oldText: string; path: string };
125
+ expect(normalizedDiff.newText).toContain('Index: /path/to/file.ts');
126
+ expect(normalizedDiff.newText).toContain('---');
127
+ expect(normalizedDiff.newText).toContain('+++');
128
+ expect(normalizedDiff.newText).toContain('-old content');
129
+ expect(normalizedDiff.newText).toContain('+new content');
130
+ expect(normalizedDiff.oldText).toBe('');
131
+ expect(normalizedDiff.path).toBe('/path/to/file.ts');
132
+ });
133
+
134
+ it('normalizes tool_call_update with diff content', () => {
135
+ const update = {
136
+ sessionUpdate: 'tool_call_update',
137
+ id: 'test',
138
+ content: [
139
+ {
140
+ type: 'diff',
141
+ oldText: 'line1\nline2',
142
+ newText: 'line1\nmodified',
143
+ path: 'test.ts',
144
+ },
145
+ ],
146
+ } as acp.SessionUpdate;
147
+
148
+ const result = normalizeSessionUpdate(update);
149
+ const diffContent = (result as { content: acp.ToolCallContent[] }).content[0] as {
150
+ newText: string;
151
+ };
152
+
153
+ expect(diffContent.newText).toContain('-line1');
154
+ expect(diffContent.newText).toContain('-line2');
155
+ expect(diffContent.newText).toContain('+line1');
156
+ expect(diffContent.newText).toContain('+modified');
157
+ });
158
+
159
+ it('handles empty oldText (new file creation)', () => {
160
+ const update = {
161
+ sessionUpdate: 'tool_call',
162
+ id: 'test',
163
+ title: 'Write File',
164
+ status: 'completed',
165
+ content: [
166
+ {
167
+ type: 'diff',
168
+ oldText: '',
169
+ newText: 'new file content',
170
+ path: 'new-file.ts',
171
+ },
172
+ ],
173
+ } as acp.SessionUpdate;
174
+
175
+ const result = normalizeSessionUpdate(update);
176
+ const diffContent = (result as { content: acp.ToolCallContent[] }).content[0] as {
177
+ newText: string;
178
+ };
179
+
180
+ expect(diffContent.newText).toContain('@@ -0,0 +1,1 @@');
181
+ expect(diffContent.newText).toContain('+new file content');
182
+ // No removed lines in the diff body (lines starting with - followed by content, not headers)
183
+ const lines = diffContent.newText.split('\n');
184
+ const removedLines = lines.filter(l => l.startsWith('-') && !l.startsWith('---'));
185
+ expect(removedLines).toHaveLength(0);
186
+ });
187
+
188
+ it('handles multi-line diffs', () => {
189
+ const update = {
190
+ sessionUpdate: 'tool_call',
191
+ id: 'test',
192
+ title: 'Edit File',
193
+ status: 'completed',
194
+ content: [
195
+ {
196
+ type: 'diff',
197
+ oldText: 'line1\nline2\nline3',
198
+ newText: 'line1\nmodified\nline3\nline4',
199
+ path: 'file.ts',
200
+ },
201
+ ],
202
+ } as acp.SessionUpdate;
203
+
204
+ const result = normalizeSessionUpdate(update);
205
+ const diffContent = (result as { content: acp.ToolCallContent[] }).content[0] as {
206
+ newText: string;
207
+ };
208
+
209
+ // Should have correct line counts in header
210
+ expect(diffContent.newText).toContain('@@ -1,3 +1,4 @@');
211
+ });
212
+
213
+ it('uses "file" as default path when path is missing', () => {
214
+ const update = {
215
+ sessionUpdate: 'tool_call',
216
+ id: 'test',
217
+ title: 'Edit',
218
+ status: 'completed',
219
+ content: [
220
+ {
221
+ type: 'diff',
222
+ oldText: 'old',
223
+ newText: 'new',
224
+ // path intentionally omitted
225
+ },
226
+ ],
227
+ } as acp.SessionUpdate;
228
+
229
+ const result = normalizeSessionUpdate(update);
230
+ const diffContent = (result as { content: acp.ToolCallContent[] }).content[0] as {
231
+ newText: string;
232
+ path: string;
233
+ };
234
+
235
+ expect(diffContent.newText).toContain('Index: file');
236
+ expect(diffContent.path).toBe('file');
237
+ });
238
+
239
+ it('preserves mixed content types in tool_call', () => {
240
+ const update = {
241
+ sessionUpdate: 'tool_call',
242
+ id: 'test',
243
+ title: 'Complex Tool',
244
+ status: 'completed',
245
+ content: [
246
+ { type: 'text', text: 'Before diff' },
247
+ {
248
+ type: 'diff',
249
+ oldText: 'old',
250
+ newText: 'new',
251
+ path: 'file.ts',
252
+ },
253
+ { type: 'text', text: 'After diff' },
254
+ ],
255
+ } as acp.SessionUpdate;
256
+
257
+ const result = normalizeSessionUpdate(update);
258
+ const content = (result as { content: acp.ToolCallContent[] }).content;
259
+
260
+ expect(content).toHaveLength(3);
261
+ expect(content[0]).toEqual({ type: 'text', text: 'Before diff' });
262
+ expect(content[1]?.type).toBe('diff');
263
+ expect(content[2]).toEqual({ type: 'text', text: 'After diff' });
264
+ });
265
+ });
package/src/session.ts CHANGED
@@ -1,9 +1,16 @@
1
- import { spawn, type ChildProcess } from 'child_process';
1
+ import { spawn, execSync, type ChildProcess } from 'child_process';
2
2
  import { EventEmitter } from 'events';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
+ import { fileURLToPath } from 'url';
5
6
  import { Writable, Readable } from 'stream';
6
7
 
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ /** Path to amux's own node_modules/.bin where ACP wrappers are installed */
12
+ const AMUX_BIN_DIR = path.join(__dirname, '..', 'node_modules', '.bin');
13
+
7
14
  import * as acp from '@agentclientprotocol/sdk';
8
15
 
9
16
  import { AmuxClient } from './client.js';
@@ -20,6 +27,32 @@ import { AGENTS as DEFAULT_AGENTS } from './types.js';
20
27
 
21
28
  const INIT_TIMEOUT_MS = 90000;
22
29
 
30
+ /**
31
+ * Check if a binary is available on PATH
32
+ */
33
+ function isOnPath(bin: string): boolean {
34
+ try {
35
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
36
+ execSync(`${cmd} ${bin}`, { stdio: 'ignore' });
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Check if an ACP wrapper binary exists in amux's node_modules
45
+ */
46
+ function hasAcpWrapper(acpBin: string): boolean {
47
+ const binPath = path.join(AMUX_BIN_DIR, acpBin);
48
+ try {
49
+ execSync(`test -x "${binPath}"`, { stdio: 'ignore' });
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
23
56
  /**
24
57
  * Events emitted by AgentSession
25
58
  */
@@ -75,7 +108,6 @@ function withTimeout<T>(promise: Promise<T>, ms: number, operation: string): Pro
75
108
  * ```typescript
76
109
  * const session = new AgentSession({
77
110
  * instanceId: 'my-instance',
78
- * basePath: __dirname,
79
111
  * });
80
112
  *
81
113
  * session.on('ready', (data) => console.log('Ready:', data));
@@ -96,6 +128,7 @@ export class AgentSession extends EventEmitter {
96
128
  private agentProcess: ChildProcess | null = null;
97
129
  private acpConnection: acp.ClientSideConnection | null = null;
98
130
  private _agentCapabilities: acp.AgentCapabilities | null = null;
131
+ private _changingCwd = false;
99
132
 
100
133
  // Components
101
134
  private terminalManager: TerminalManager;
@@ -104,7 +137,6 @@ export class AgentSession extends EventEmitter {
104
137
 
105
138
  // Config
106
139
  private instanceId: string;
107
- private basePath: string;
108
140
  private fixedCwd?: string;
109
141
  private agents: Record<string, AgentConfig>;
110
142
 
@@ -112,7 +144,6 @@ export class AgentSession extends EventEmitter {
112
144
  super();
113
145
 
114
146
  this.instanceId = config.instanceId;
115
- this.basePath = config.basePath;
116
147
  this.fixedCwd = config.fixedCwd;
117
148
  this._systemContext = config.systemContext;
118
149
  this.agents = DEFAULT_AGENTS;
@@ -187,10 +218,23 @@ export class AgentSession extends EventEmitter {
187
218
  }
188
219
 
189
220
  /**
190
- * Get available agents list
221
+ * Get available agents list (checks if base CLI is on PATH and ACP wrapper is bundled)
222
+ */
223
+ getAvailableAgents(): Array<{ id: string; name: string; installed: boolean }> {
224
+ return Object.entries(this.agents).map(([id, a]) => ({
225
+ id,
226
+ name: a.name,
227
+ installed: isOnPath(a.cli) && hasAcpWrapper(a.acpBin),
228
+ }));
229
+ }
230
+
231
+ /**
232
+ * Get only installed agents
191
233
  */
192
- getAvailableAgents(): Array<{ id: string; name: string }> {
193
- return Object.entries(this.agents).map(([id, a]) => ({ id, name: a.name }));
234
+ getInstalledAgents(): Array<{ id: string; name: string }> {
235
+ return this.getAvailableAgents()
236
+ .filter((a) => a.installed)
237
+ .map(({ id, name }) => ({ id, name }));
194
238
  }
195
239
 
196
240
  /**
@@ -243,11 +287,29 @@ export class AgentSession extends EventEmitter {
243
287
  return;
244
288
  }
245
289
 
290
+ // Check if base CLI is installed
291
+ if (!isOnPath(agent.cli)) {
292
+ console.error(`[amux] Agent CLI not found on PATH: ${agent.cli}`);
293
+ this.emit('error', {
294
+ message: `Agent "${agent.cli}" not found. Install it globally or add it to your PATH.`,
295
+ });
296
+ return;
297
+ }
298
+
299
+ // Check if ACP wrapper is bundled
300
+ const acpBinPath = path.join(AMUX_BIN_DIR, agent.acpBin);
301
+ if (!hasAcpWrapper(agent.acpBin)) {
302
+ console.error(`[amux] ACP wrapper not found: ${acpBinPath}`);
303
+ this.emit('error', {
304
+ message: `ACP wrapper "${agent.acpBin}" not bundled. This is an amux installation issue.`,
305
+ });
306
+ return;
307
+ }
308
+
246
309
  console.log(`[amux] Spawning ${agent.name} agent in cwd:`, this._cwd);
247
310
  this.emit('connecting', {});
248
311
 
249
- const agentBin = path.join(this.basePath, 'node_modules', '.bin', agent.bin);
250
- const newProcess = spawn(agentBin, [], {
312
+ const newProcess = spawn(acpBinPath, [], {
251
313
  stdio: ['pipe', 'pipe', 'pipe'],
252
314
  env: { ...process.env },
253
315
  cwd: this._cwd,
@@ -323,7 +385,7 @@ export class AgentSession extends EventEmitter {
323
385
  sessionRestore,
324
386
  capabilities: this._agentCapabilities,
325
387
  agent: this.getAgentInfo(),
326
- availableAgents: this.getAvailableAgents(),
388
+ availableAgents: this.getInstalledAgents(),
327
389
  });
328
390
 
329
391
  } catch (err) {
@@ -617,12 +679,29 @@ export class AgentSession extends EventEmitter {
617
679
  throw new Error('Working directory is fixed for this session');
618
680
  }
619
681
 
620
- this._cwd = newPath;
621
- this._sessionId = null;
622
- this.saveState();
682
+ // Prevent concurrent cwd changes
683
+ if (this._changingCwd) {
684
+ console.log('[amux] changeCwd already in progress, ignoring');
685
+ return;
686
+ }
623
687
 
624
- await this.killAgent();
625
- await this.spawnAgent();
688
+ // Same path, nothing to do
689
+ if (newPath === this._cwd) {
690
+ console.log('[amux] changeCwd same path, ignoring');
691
+ return;
692
+ }
693
+
694
+ this._changingCwd = true;
695
+ try {
696
+ this._cwd = newPath;
697
+ this._sessionId = null;
698
+ this.saveState();
699
+
700
+ await this.killAgent();
701
+ await this.spawnAgent();
702
+ } finally {
703
+ this._changingCwd = false;
704
+ }
626
705
  }
627
706
 
628
707
  /**
package/src/types.ts CHANGED
@@ -5,7 +5,10 @@ import type * as acp from '@agentclientprotocol/sdk';
5
5
  */
6
6
  export interface AgentConfig {
7
7
  name: string;
8
- bin: string;
8
+ /** The base CLI binary name (for checking if installed) */
9
+ cli: string;
10
+ /** The ACP wrapper binary name (bundled with amux) */
11
+ acpBin: string;
9
12
  }
10
13
 
11
14
  /**
@@ -14,15 +17,18 @@ export interface AgentConfig {
14
17
  export const AGENTS: Record<string, AgentConfig> = {
15
18
  'claude-code': {
16
19
  name: 'Claude Code',
17
- bin: 'claude-code-acp',
20
+ cli: 'claude',
21
+ acpBin: 'claude-code-acp',
18
22
  },
19
23
  'codex': {
20
24
  name: 'Codex',
21
- bin: 'codex-acp',
25
+ cli: 'codex',
26
+ acpBin: 'codex-acp',
22
27
  },
23
28
  'pi': {
24
29
  name: 'Pi',
25
- bin: 'pi-acp',
30
+ cli: 'pi',
31
+ acpBin: 'pi-acp',
26
32
  },
27
33
  };
28
34
 
@@ -71,9 +77,6 @@ export interface AgentSessionConfig {
71
77
  /** Unique identifier for this session instance */
72
78
  instanceId: string;
73
79
 
74
- /** Base path for resolving agent binaries (node_modules/.bin) */
75
- basePath: string;
76
-
77
80
  /** Optional system context to inject (e.g., from a markdown file) */
78
81
  systemContext?: string;
79
82
 
package/tsconfig.json CHANGED
@@ -18,5 +18,5 @@
18
18
  "noUnusedParameters": true
19
19
  },
20
20
  "include": ["src/**/*"],
21
- "exclude": ["node_modules", "dist"]
21
+ "exclude": ["node_modules", "dist", "src/**/*.test.ts"]
22
22
  }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/**/*.test.ts'],
6
+ },
7
+ });