tmux-team 1.0.0 → 2.0.0-alpha.1
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/README.md +197 -24
- package/bin/tmux-team +31 -430
- package/package.json +26 -6
- package/src/cli.ts +212 -0
- package/src/commands/add.ts +38 -0
- package/src/commands/check.ts +34 -0
- package/src/commands/completion.ts +118 -0
- package/src/commands/help.ts +51 -0
- package/src/commands/init.ts +24 -0
- package/src/commands/list.ts +27 -0
- package/src/commands/remove.ts +25 -0
- package/src/commands/talk.test.ts +652 -0
- package/src/commands/talk.ts +261 -0
- package/src/commands/update.ts +47 -0
- package/src/config.test.ts +246 -0
- package/src/config.ts +159 -0
- package/src/context.ts +38 -0
- package/src/exits.ts +14 -0
- package/src/pm/commands.test.ts +158 -0
- package/src/pm/commands.ts +654 -0
- package/src/pm/manager.test.ts +377 -0
- package/src/pm/manager.ts +140 -0
- package/src/pm/storage/adapter.ts +55 -0
- package/src/pm/storage/fs.test.ts +384 -0
- package/src/pm/storage/fs.ts +255 -0
- package/src/pm/storage/github.ts +751 -0
- package/src/pm/types.ts +79 -0
- package/src/state.test.ts +311 -0
- package/src/state.ts +83 -0
- package/src/tmux.test.ts +205 -0
- package/src/tmux.ts +27 -0
- package/src/types.ts +86 -0
- package/src/ui.ts +67 -0
- package/src/version.ts +21 -0
package/src/pm/types.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Project Management types
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type TaskStatus = 'pending' | 'in_progress' | 'done';
|
|
6
|
+
export type MilestoneStatus = 'pending' | 'in_progress' | 'done';
|
|
7
|
+
|
|
8
|
+
export interface Team {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
windowId?: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Milestone {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
status: MilestoneStatus;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Task {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
milestone?: string;
|
|
27
|
+
status: TaskStatus;
|
|
28
|
+
assignee?: string;
|
|
29
|
+
docPath?: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AuditEvent {
|
|
35
|
+
event: string;
|
|
36
|
+
id: string;
|
|
37
|
+
actor: string;
|
|
38
|
+
ts: string;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CreateTaskInput {
|
|
43
|
+
title: string;
|
|
44
|
+
milestone?: string;
|
|
45
|
+
assignee?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UpdateTaskInput {
|
|
49
|
+
status?: TaskStatus;
|
|
50
|
+
assignee?: string;
|
|
51
|
+
title?: string;
|
|
52
|
+
milestone?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CreateMilestoneInput {
|
|
56
|
+
name: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UpdateMilestoneInput {
|
|
60
|
+
name?: string;
|
|
61
|
+
status?: MilestoneStatus;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ListTasksFilter {
|
|
65
|
+
milestone?: string;
|
|
66
|
+
status?: TaskStatus;
|
|
67
|
+
assignee?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─────────────────────────────────────────────────────────────
|
|
71
|
+
// Storage backend configuration
|
|
72
|
+
// ─────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export type StorageBackend = 'fs' | 'github';
|
|
75
|
+
|
|
76
|
+
export interface TeamConfig {
|
|
77
|
+
backend: StorageBackend;
|
|
78
|
+
repo?: string; // GitHub repo (owner/repo format) for github backend
|
|
79
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// State Tests - Request tracking and TTL cleanup
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import type { Paths } from './types.js';
|
|
10
|
+
import {
|
|
11
|
+
loadState,
|
|
12
|
+
saveState,
|
|
13
|
+
cleanupState,
|
|
14
|
+
setActiveRequest,
|
|
15
|
+
clearActiveRequest,
|
|
16
|
+
type AgentRequestState,
|
|
17
|
+
} from './state.js';
|
|
18
|
+
|
|
19
|
+
describe('State Management', () => {
|
|
20
|
+
let testDir: string;
|
|
21
|
+
let paths: Paths;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-state-test-'));
|
|
25
|
+
paths = {
|
|
26
|
+
globalDir: testDir,
|
|
27
|
+
globalConfig: path.join(testDir, 'config.json'),
|
|
28
|
+
localConfig: path.join(testDir, 'tmux-team.json'),
|
|
29
|
+
stateFile: path.join(testDir, 'state.json'),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
if (fs.existsSync(testDir)) {
|
|
35
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('loadState', () => {
|
|
40
|
+
it('returns empty state when state.json does not exist', () => {
|
|
41
|
+
const state = loadState(paths);
|
|
42
|
+
expect(state.requests).toEqual({});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('loads existing state from state.json', () => {
|
|
46
|
+
const existingState = {
|
|
47
|
+
requests: {
|
|
48
|
+
claude: { id: 'req-1', nonce: 'abc123', pane: '1.0', startedAtMs: 1000 },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
fs.writeFileSync(paths.stateFile, JSON.stringify(existingState));
|
|
52
|
+
|
|
53
|
+
const state = loadState(paths);
|
|
54
|
+
expect(state.requests.claude).toBeDefined();
|
|
55
|
+
expect(state.requests.claude?.nonce).toBe('abc123');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns empty state when state.json is corrupted', () => {
|
|
59
|
+
fs.writeFileSync(paths.stateFile, 'not valid json');
|
|
60
|
+
|
|
61
|
+
const state = loadState(paths);
|
|
62
|
+
expect(state.requests).toEqual({});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns empty state when state.json has invalid structure', () => {
|
|
66
|
+
fs.writeFileSync(paths.stateFile, JSON.stringify({ invalid: true }));
|
|
67
|
+
|
|
68
|
+
const state = loadState(paths);
|
|
69
|
+
expect(state.requests).toEqual({});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('saveState', () => {
|
|
74
|
+
it('writes state to state.json', () => {
|
|
75
|
+
const state = {
|
|
76
|
+
requests: {
|
|
77
|
+
claude: { id: 'req-1', nonce: 'xyz', pane: '1.0', startedAtMs: 2000 },
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
saveState(paths, state);
|
|
82
|
+
|
|
83
|
+
expect(fs.existsSync(paths.stateFile)).toBe(true);
|
|
84
|
+
const saved = JSON.parse(fs.readFileSync(paths.stateFile, 'utf-8'));
|
|
85
|
+
expect(saved.requests.claude.nonce).toBe('xyz');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('creates globalDir if it does not exist', () => {
|
|
89
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
90
|
+
expect(fs.existsSync(testDir)).toBe(false);
|
|
91
|
+
|
|
92
|
+
saveState(paths, { requests: {} });
|
|
93
|
+
|
|
94
|
+
expect(fs.existsSync(testDir)).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('cleanupState', () => {
|
|
99
|
+
it('removes entries older than TTL', () => {
|
|
100
|
+
const oldTime = Date.now() - 120 * 1000; // 2 minutes ago
|
|
101
|
+
const state = {
|
|
102
|
+
requests: {
|
|
103
|
+
oldAgent: { id: 'old', nonce: 'old', pane: '1.0', startedAtMs: oldTime },
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
107
|
+
fs.writeFileSync(paths.stateFile, JSON.stringify(state));
|
|
108
|
+
|
|
109
|
+
const cleaned = cleanupState(paths, 60); // 60 second TTL
|
|
110
|
+
|
|
111
|
+
expect(cleaned.requests.oldAgent).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('keeps entries within TTL', () => {
|
|
115
|
+
const recentTime = Date.now() - 30 * 1000; // 30 seconds ago
|
|
116
|
+
const state = {
|
|
117
|
+
requests: {
|
|
118
|
+
recentAgent: { id: 'new', nonce: 'new', pane: '1.0', startedAtMs: recentTime },
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
122
|
+
fs.writeFileSync(paths.stateFile, JSON.stringify(state));
|
|
123
|
+
|
|
124
|
+
const cleaned = cleanupState(paths, 60); // 60 second TTL
|
|
125
|
+
|
|
126
|
+
expect(cleaned.requests.recentAgent).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('requires ttlSeconds parameter', () => {
|
|
130
|
+
// TypeScript enforces this - the function requires ttlSeconds
|
|
131
|
+
// This test just verifies the behavior with different TTL values
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const state = {
|
|
134
|
+
requests: {
|
|
135
|
+
agent1: { id: '1', nonce: 'a', pane: '1.0', startedAtMs: now - 5000 }, // 5s ago
|
|
136
|
+
agent2: { id: '2', nonce: 'b', pane: '1.1', startedAtMs: now - 15000 }, // 15s ago
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
140
|
+
fs.writeFileSync(paths.stateFile, JSON.stringify(state));
|
|
141
|
+
|
|
142
|
+
// With 10s TTL, agent1 should be kept, agent2 removed
|
|
143
|
+
const cleaned = cleanupState(paths, 10);
|
|
144
|
+
|
|
145
|
+
expect(cleaned.requests.agent1).toBeDefined();
|
|
146
|
+
expect(cleaned.requests.agent2).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('handles entries with missing startedAtMs', () => {
|
|
150
|
+
const state = {
|
|
151
|
+
requests: {
|
|
152
|
+
badAgent: { id: 'bad', nonce: 'bad', pane: '1.0' } as AgentRequestState,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
156
|
+
fs.writeFileSync(paths.stateFile, JSON.stringify(state));
|
|
157
|
+
|
|
158
|
+
const cleaned = cleanupState(paths, 60);
|
|
159
|
+
|
|
160
|
+
// Entry with invalid startedAtMs is removed
|
|
161
|
+
expect(cleaned.requests.badAgent).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('only rewrites file if entries were removed', () => {
|
|
165
|
+
const recentTime = Date.now();
|
|
166
|
+
const state = {
|
|
167
|
+
requests: {
|
|
168
|
+
agent: { id: '1', nonce: 'a', pane: '1.0', startedAtMs: recentTime },
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
172
|
+
fs.writeFileSync(paths.stateFile, JSON.stringify(state));
|
|
173
|
+
const originalMtime = fs.statSync(paths.stateFile).mtimeMs;
|
|
174
|
+
|
|
175
|
+
// Wait a tiny bit then cleanup (nothing should change)
|
|
176
|
+
cleanupState(paths, 3600);
|
|
177
|
+
|
|
178
|
+
const newMtime = fs.statSync(paths.stateFile).mtimeMs;
|
|
179
|
+
// File should not have been rewritten
|
|
180
|
+
expect(newMtime).toBe(originalMtime);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('setActiveRequest', () => {
|
|
185
|
+
it('adds new request to state', () => {
|
|
186
|
+
const req: AgentRequestState = {
|
|
187
|
+
id: 'req-1',
|
|
188
|
+
nonce: 'nonce123',
|
|
189
|
+
pane: '1.0',
|
|
190
|
+
startedAtMs: Date.now(),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
setActiveRequest(paths, 'claude', req);
|
|
194
|
+
|
|
195
|
+
const state = loadState(paths);
|
|
196
|
+
expect(state.requests.claude).toBeDefined();
|
|
197
|
+
expect(state.requests.claude?.id).toBe('req-1');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('stores request with id, nonce, pane, and startedAtMs', () => {
|
|
201
|
+
const req: AgentRequestState = {
|
|
202
|
+
id: 'test-id',
|
|
203
|
+
nonce: 'test-nonce',
|
|
204
|
+
pane: '2.1',
|
|
205
|
+
startedAtMs: 1234567890,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
setActiveRequest(paths, 'codex', req);
|
|
209
|
+
|
|
210
|
+
const state = loadState(paths);
|
|
211
|
+
expect(state.requests.codex?.id).toBe('test-id');
|
|
212
|
+
expect(state.requests.codex?.nonce).toBe('test-nonce');
|
|
213
|
+
expect(state.requests.codex?.pane).toBe('2.1');
|
|
214
|
+
expect(state.requests.codex?.startedAtMs).toBe(1234567890);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('overwrites existing request for same agent', () => {
|
|
218
|
+
const req1: AgentRequestState = {
|
|
219
|
+
id: 'old',
|
|
220
|
+
nonce: 'old-nonce',
|
|
221
|
+
pane: '1.0',
|
|
222
|
+
startedAtMs: 1000,
|
|
223
|
+
};
|
|
224
|
+
const req2: AgentRequestState = {
|
|
225
|
+
id: 'new',
|
|
226
|
+
nonce: 'new-nonce',
|
|
227
|
+
pane: '1.0',
|
|
228
|
+
startedAtMs: 2000,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
setActiveRequest(paths, 'claude', req1);
|
|
232
|
+
setActiveRequest(paths, 'claude', req2);
|
|
233
|
+
|
|
234
|
+
const state = loadState(paths);
|
|
235
|
+
expect(state.requests.claude?.id).toBe('new');
|
|
236
|
+
expect(state.requests.claude?.nonce).toBe('new-nonce');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('preserves other agents when adding new one', () => {
|
|
240
|
+
const req1: AgentRequestState = { id: '1', nonce: 'a', pane: '1.0', startedAtMs: 1000 };
|
|
241
|
+
const req2: AgentRequestState = { id: '2', nonce: 'b', pane: '1.1', startedAtMs: 2000 };
|
|
242
|
+
|
|
243
|
+
setActiveRequest(paths, 'claude', req1);
|
|
244
|
+
setActiveRequest(paths, 'codex', req2);
|
|
245
|
+
|
|
246
|
+
const state = loadState(paths);
|
|
247
|
+
expect(state.requests.claude).toBeDefined();
|
|
248
|
+
expect(state.requests.codex).toBeDefined();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('clearActiveRequest', () => {
|
|
253
|
+
it('removes agent request from state', () => {
|
|
254
|
+
const req: AgentRequestState = { id: '1', nonce: 'a', pane: '1.0', startedAtMs: 1000 };
|
|
255
|
+
setActiveRequest(paths, 'claude', req);
|
|
256
|
+
|
|
257
|
+
clearActiveRequest(paths, 'claude');
|
|
258
|
+
|
|
259
|
+
const state = loadState(paths);
|
|
260
|
+
expect(state.requests.claude).toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('does nothing if agent has no active request', () => {
|
|
264
|
+
// Should not throw
|
|
265
|
+
const stateBefore = loadState(paths);
|
|
266
|
+
const countBefore = Object.keys(stateBefore.requests).length;
|
|
267
|
+
|
|
268
|
+
clearActiveRequest(paths, 'nonexistent');
|
|
269
|
+
|
|
270
|
+
const stateAfter = loadState(paths);
|
|
271
|
+
const countAfter = Object.keys(stateAfter.requests).length;
|
|
272
|
+
|
|
273
|
+
// Count should be unchanged (no error, no modification)
|
|
274
|
+
expect(countAfter).toBe(countBefore);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('only clears if requestId matches when provided', () => {
|
|
278
|
+
const req: AgentRequestState = { id: 'req-1', nonce: 'a', pane: '1.0', startedAtMs: 1000 };
|
|
279
|
+
setActiveRequest(paths, 'claude', req);
|
|
280
|
+
|
|
281
|
+
// Try to clear with wrong requestId
|
|
282
|
+
clearActiveRequest(paths, 'claude', 'wrong-id');
|
|
283
|
+
|
|
284
|
+
const state = loadState(paths);
|
|
285
|
+
expect(state.requests.claude).toBeDefined(); // Should still exist
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('clears when requestId matches', () => {
|
|
289
|
+
const req: AgentRequestState = { id: 'req-1', nonce: 'a', pane: '1.0', startedAtMs: 1000 };
|
|
290
|
+
setActiveRequest(paths, 'claude', req);
|
|
291
|
+
|
|
292
|
+
clearActiveRequest(paths, 'claude', 'req-1');
|
|
293
|
+
|
|
294
|
+
const state = loadState(paths);
|
|
295
|
+
expect(state.requests.claude).toBeUndefined();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('preserves other agents when clearing one', () => {
|
|
299
|
+
const req1: AgentRequestState = { id: '1', nonce: 'a', pane: '1.0', startedAtMs: 1000 };
|
|
300
|
+
const req2: AgentRequestState = { id: '2', nonce: 'b', pane: '1.1', startedAtMs: 2000 };
|
|
301
|
+
setActiveRequest(paths, 'claude', req1);
|
|
302
|
+
setActiveRequest(paths, 'codex', req2);
|
|
303
|
+
|
|
304
|
+
clearActiveRequest(paths, 'claude');
|
|
305
|
+
|
|
306
|
+
const state = loadState(paths);
|
|
307
|
+
expect(state.requests.claude).toBeUndefined();
|
|
308
|
+
expect(state.requests.codex).toBeDefined();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// State management for wait-mode requests (soft locks + cleanup)
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import type { Paths } from './types.js';
|
|
7
|
+
import { ensureGlobalDir } from './config.js';
|
|
8
|
+
|
|
9
|
+
export interface AgentRequestState {
|
|
10
|
+
id: string;
|
|
11
|
+
nonce: string;
|
|
12
|
+
pane: string;
|
|
13
|
+
startedAtMs: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StateFile {
|
|
17
|
+
requests: Record<string, AgentRequestState>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_STATE: StateFile = { requests: {} };
|
|
21
|
+
|
|
22
|
+
function safeParseJson<T>(text: string): T | null {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(text) as T;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadState(paths: Paths): StateFile {
|
|
31
|
+
ensureGlobalDir(paths);
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(paths.stateFile)) {
|
|
34
|
+
return { ...DEFAULT_STATE };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const raw = fs.readFileSync(paths.stateFile, 'utf-8');
|
|
38
|
+
const parsed = safeParseJson<StateFile>(raw);
|
|
39
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.requests) {
|
|
40
|
+
return { ...DEFAULT_STATE };
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function saveState(paths: Paths, state: StateFile): void {
|
|
46
|
+
ensureGlobalDir(paths);
|
|
47
|
+
fs.writeFileSync(paths.stateFile, JSON.stringify(state, null, 2) + '\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function cleanupState(paths: Paths, ttlSeconds: number): StateFile {
|
|
51
|
+
const state = loadState(paths);
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
|
|
54
|
+
const ttlMs = Math.max(1, ttlSeconds) * 1000;
|
|
55
|
+
const next: StateFile = { requests: {} };
|
|
56
|
+
|
|
57
|
+
for (const [agent, req] of Object.entries(state.requests)) {
|
|
58
|
+
if (!req || typeof req.startedAtMs !== 'number') continue;
|
|
59
|
+
if (now - req.startedAtMs <= ttlMs) {
|
|
60
|
+
next.requests[agent] = req;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Only rewrite if it changed materially
|
|
65
|
+
if (Object.keys(next.requests).length !== Object.keys(state.requests).length) {
|
|
66
|
+
saveState(paths, next);
|
|
67
|
+
}
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function setActiveRequest(paths: Paths, agent: string, req: AgentRequestState): void {
|
|
72
|
+
const state = loadState(paths);
|
|
73
|
+
state.requests[agent] = req;
|
|
74
|
+
saveState(paths, state);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function clearActiveRequest(paths: Paths, agent: string, requestId?: string): void {
|
|
78
|
+
const state = loadState(paths);
|
|
79
|
+
if (!state.requests[agent]) return;
|
|
80
|
+
if (requestId && state.requests[agent]?.id !== requestId) return;
|
|
81
|
+
delete state.requests[agent];
|
|
82
|
+
saveState(paths, state);
|
|
83
|
+
}
|
package/src/tmux.test.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Tmux Wrapper Tests - send-keys, capture-pane
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import { createTmux } from './tmux.js';
|
|
8
|
+
|
|
9
|
+
// Mock child_process
|
|
10
|
+
vi.mock('child_process', () => ({
|
|
11
|
+
execSync: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const mockedExecSync = vi.mocked(execSync);
|
|
15
|
+
|
|
16
|
+
describe('createTmux', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('send', () => {
|
|
26
|
+
it('calls tmux send-keys with pane ID and message', () => {
|
|
27
|
+
const tmux = createTmux();
|
|
28
|
+
|
|
29
|
+
tmux.send('1.0', 'Hello world');
|
|
30
|
+
|
|
31
|
+
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
32
|
+
'tmux send-keys -t "1.0" "Hello world"',
|
|
33
|
+
expect.any(Object)
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('sends Enter key after message', () => {
|
|
38
|
+
const tmux = createTmux();
|
|
39
|
+
|
|
40
|
+
tmux.send('1.0', 'Hello');
|
|
41
|
+
|
|
42
|
+
expect(mockedExecSync).toHaveBeenCalledTimes(2);
|
|
43
|
+
expect(mockedExecSync).toHaveBeenNthCalledWith(
|
|
44
|
+
2,
|
|
45
|
+
'tmux send-keys -t "1.0" Enter',
|
|
46
|
+
expect.any(Object)
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('escapes special characters in message', () => {
|
|
51
|
+
const tmux = createTmux();
|
|
52
|
+
|
|
53
|
+
tmux.send('1.0', 'Hello "world" with \'quotes\'');
|
|
54
|
+
|
|
55
|
+
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
56
|
+
expect.stringContaining('"Hello \\"world\\" with \'quotes\'"'),
|
|
57
|
+
expect.any(Object)
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('handles newlines in message', () => {
|
|
62
|
+
const tmux = createTmux();
|
|
63
|
+
|
|
64
|
+
tmux.send('1.0', 'Line 1\nLine 2');
|
|
65
|
+
|
|
66
|
+
// JSON.stringify escapes newlines as \n
|
|
67
|
+
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
68
|
+
expect.stringContaining('"Line 1\\nLine 2"'),
|
|
69
|
+
expect.any(Object)
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('throws when pane does not exist', () => {
|
|
74
|
+
const error = new Error("can't find pane: 99.99");
|
|
75
|
+
mockedExecSync.mockImplementationOnce(() => {
|
|
76
|
+
throw error;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const tmux = createTmux();
|
|
80
|
+
|
|
81
|
+
expect(() => tmux.send('99.99', 'Hello')).toThrow("can't find pane: 99.99");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('uses pipe stdio to suppress output', () => {
|
|
85
|
+
const tmux = createTmux();
|
|
86
|
+
|
|
87
|
+
tmux.send('1.0', 'Hello');
|
|
88
|
+
|
|
89
|
+
expect(mockedExecSync).toHaveBeenCalledWith(expect.any(String), { stdio: 'pipe' });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('capture', () => {
|
|
94
|
+
it('calls tmux capture-pane with pane ID and line count', () => {
|
|
95
|
+
mockedExecSync.mockReturnValue('captured output');
|
|
96
|
+
const tmux = createTmux();
|
|
97
|
+
|
|
98
|
+
tmux.capture('1.0', 100);
|
|
99
|
+
|
|
100
|
+
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
101
|
+
'tmux capture-pane -t "1.0" -p -S -100',
|
|
102
|
+
expect.any(Object)
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns captured pane content', () => {
|
|
107
|
+
const expectedOutput = 'Line 1\nLine 2\nLine 3';
|
|
108
|
+
mockedExecSync.mockReturnValue(expectedOutput);
|
|
109
|
+
const tmux = createTmux();
|
|
110
|
+
|
|
111
|
+
const result = tmux.capture('1.0', 50);
|
|
112
|
+
|
|
113
|
+
expect(result).toBe(expectedOutput);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('captures specified number of lines', () => {
|
|
117
|
+
mockedExecSync.mockReturnValue('');
|
|
118
|
+
const tmux = createTmux();
|
|
119
|
+
|
|
120
|
+
tmux.capture('2.1', 200);
|
|
121
|
+
|
|
122
|
+
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
123
|
+
'tmux capture-pane -t "2.1" -p -S -200',
|
|
124
|
+
expect.any(Object)
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('throws when pane does not exist', () => {
|
|
129
|
+
const error = new Error("can't find pane: 99.99");
|
|
130
|
+
mockedExecSync.mockImplementationOnce(() => {
|
|
131
|
+
throw error;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const tmux = createTmux();
|
|
135
|
+
|
|
136
|
+
expect(() => tmux.capture('99.99', 100)).toThrow("can't find pane: 99.99");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('uses utf-8 encoding for output', () => {
|
|
140
|
+
mockedExecSync.mockReturnValue('');
|
|
141
|
+
const tmux = createTmux();
|
|
142
|
+
|
|
143
|
+
tmux.capture('1.0', 100);
|
|
144
|
+
|
|
145
|
+
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
146
|
+
expect.any(String),
|
|
147
|
+
expect.objectContaining({ encoding: 'utf-8' })
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('uses pipe stdio for all streams', () => {
|
|
152
|
+
mockedExecSync.mockReturnValue('');
|
|
153
|
+
const tmux = createTmux();
|
|
154
|
+
|
|
155
|
+
tmux.capture('1.0', 100);
|
|
156
|
+
|
|
157
|
+
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
158
|
+
expect.any(String),
|
|
159
|
+
expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] })
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('pane ID handling', () => {
|
|
165
|
+
it('accepts window.pane format', () => {
|
|
166
|
+
mockedExecSync.mockReturnValue('');
|
|
167
|
+
const tmux = createTmux();
|
|
168
|
+
|
|
169
|
+
tmux.send('1.2', 'Hello');
|
|
170
|
+
tmux.capture('1.2', 100);
|
|
171
|
+
|
|
172
|
+
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
173
|
+
expect.stringContaining('-t "1.2"'),
|
|
174
|
+
expect.any(Object)
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('accepts session:window.pane format', () => {
|
|
179
|
+
mockedExecSync.mockReturnValue('');
|
|
180
|
+
const tmux = createTmux();
|
|
181
|
+
|
|
182
|
+
tmux.send('main:1.2', 'Hello');
|
|
183
|
+
tmux.capture('main:1.2', 100);
|
|
184
|
+
|
|
185
|
+
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
186
|
+
expect.stringContaining('-t "main:1.2"'),
|
|
187
|
+
expect.any(Object)
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('quotes pane ID to prevent shell injection', () => {
|
|
192
|
+
mockedExecSync.mockReturnValue('');
|
|
193
|
+
const tmux = createTmux();
|
|
194
|
+
|
|
195
|
+
// Malicious pane ID attempt
|
|
196
|
+
tmux.send('1.0; rm -rf /', 'Hello');
|
|
197
|
+
|
|
198
|
+
// Should be quoted and treated as literal string
|
|
199
|
+
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
200
|
+
'tmux send-keys -t "1.0; rm -rf /" "Hello"',
|
|
201
|
+
expect.any(Object)
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/tmux.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Pure tmux wrapper - send-keys, capture-pane
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import type { Tmux } from './types.js';
|
|
7
|
+
|
|
8
|
+
export function createTmux(): Tmux {
|
|
9
|
+
return {
|
|
10
|
+
send(paneId: string, message: string): void {
|
|
11
|
+
execSync(`tmux send-keys -t "${paneId}" ${JSON.stringify(message)}`, {
|
|
12
|
+
stdio: 'pipe',
|
|
13
|
+
});
|
|
14
|
+
execSync(`tmux send-keys -t "${paneId}" Enter`, {
|
|
15
|
+
stdio: 'pipe',
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
capture(paneId: string, lines: number): string {
|
|
20
|
+
const output = execSync(`tmux capture-pane -t "${paneId}" -p -S -${lines}`, {
|
|
21
|
+
encoding: 'utf-8',
|
|
22
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
23
|
+
});
|
|
24
|
+
return output;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|