@assistkick/create 1.2.0 → 1.3.0
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 +2 -1
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +231 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +49 -2
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +82 -0
- package/templates/assistkick-product-system/packages/backend/src/server.ts +19 -6
- package/templates/assistkick-product-system/packages/backend/src/services/github_app_service.ts +146 -0
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +69 -2
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +71 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +87 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +194 -0
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -17
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +114 -39
- package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +28 -14
- package/templates/assistkick-product-system/packages/frontend/src/App.tsx +1 -1
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +151 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +352 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +208 -95
- package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +17 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +238 -105
- package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +15 -13
- package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +1 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +4 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/dashboard.tsx +22 -4
- package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +486 -38
- package/templates/assistkick-product-system/packages/shared/db/migrations/0001_vengeful_wallop.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0002_greedy_excalibur.sql +4 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0001_snapshot.json +826 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0002_snapshot.json +854 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +14 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +5 -0
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +54 -1
- package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +25 -0
- package/templates/assistkick-product-system/packages/shared/lib/pipeline-state-store.ts +4 -0
- package/templates/assistkick-product-system/packages/shared/lib/pipeline.ts +329 -89
- package/templates/assistkick-product-system/packages/shared/lib/pipeline_orchestrator.ts +186 -0
- package/templates/assistkick-product-system/packages/shared/tools/db_explorer.ts +275 -0
- package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/move_card.ts +3 -2
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
- package/templates/assistkick-product-system/tests/kanban.test.ts +1 -1
- package/templates/assistkick-product-system/tests/pipeline_stats_all_cards.test.ts +1 -1
- package/templates/assistkick-product-system/tests/web_terminal.test.ts +189 -150
- package/templates/skills/assistkick-bootstrap/SKILL.md +33 -25
- package/templates/skills/assistkick-code-reviewer/SKILL.md +23 -15
- package/templates/skills/assistkick-db-explorer/SKILL.md +86 -0
- package/templates/skills/assistkick-debugger/SKILL.md +30 -22
- package/templates/skills/assistkick-developer/SKILL.md +37 -29
- package/templates/skills/assistkick-interview/SKILL.md +34 -26
|
@@ -18,7 +18,7 @@ describe('Command Prefix Whitelist (dec_029)', () => {
|
|
|
18
18
|
resize: mock.fn(),
|
|
19
19
|
kill: mock.fn(),
|
|
20
20
|
}));
|
|
21
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
21
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
22
22
|
|
|
23
23
|
it('allows "claude" command', () => {
|
|
24
24
|
const result = manager.validateCommand('claude');
|
|
@@ -85,47 +85,54 @@ describe('Command Prefix Whitelist (dec_029)', () => {
|
|
|
85
85
|
// --- PTY Session Manager — session lifecycle ---
|
|
86
86
|
|
|
87
87
|
describe('PtySessionManager session lifecycle', () => {
|
|
88
|
-
it('
|
|
88
|
+
it('auto-launches claude on session creation', () => {
|
|
89
89
|
const mockLog = mock.fn();
|
|
90
|
-
const
|
|
91
|
-
const
|
|
90
|
+
const mockPty = { onData: mock.fn(), onExit: mock.fn(), write: mock.fn(), resize: mock.fn(), kill: mock.fn() };
|
|
91
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
92
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
92
93
|
|
|
93
|
-
const session = manager.createSession('
|
|
94
|
+
const session = manager.createSession('proj-1', 'Project One', 80, 24);
|
|
94
95
|
assert.ok(session);
|
|
95
|
-
assert.equal(session.
|
|
96
|
-
assert.equal(session.
|
|
97
|
-
assert.equal(session.
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
assert.equal(session.projectId, 'proj-1');
|
|
97
|
+
assert.equal(session.projectName, 'Project One');
|
|
98
|
+
assert.equal(session.state, 'running'); // claude auto-launches on creation
|
|
99
|
+
assert.ok(session.id.startsWith('term_'));
|
|
100
|
+
// claude was spawned automatically with project context
|
|
101
|
+
assert.equal(mockSpawn.mock.calls.length, 1);
|
|
102
|
+
assert.equal(mockSpawn.mock.calls[0].arguments[0], 'claude');
|
|
103
|
+
const args = mockSpawn.mock.calls[0].arguments[1] as string[];
|
|
104
|
+
assert.ok(args.includes('--dangerously-skip-permissions'));
|
|
105
|
+
assert.ok(args.includes('--append-system-prompt'));
|
|
106
|
+
assert.ok(args.some((a: string) => a.includes('proj-1')));
|
|
100
107
|
});
|
|
101
108
|
|
|
102
|
-
it('
|
|
109
|
+
it('creates separate sessions for the same project (no uniqueness constraint)', () => {
|
|
103
110
|
const mockLog = mock.fn();
|
|
104
|
-
const mockSpawn = mock.fn();
|
|
105
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
111
|
+
const mockSpawn = mock.fn(() => ({ onData: mock.fn(), onExit: mock.fn(), write: mock.fn(), resize: mock.fn(), kill: mock.fn() }));
|
|
112
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
106
113
|
|
|
107
|
-
const session1 = manager.createSession('
|
|
108
|
-
const session2 = manager.createSession('
|
|
109
|
-
assert.
|
|
110
|
-
assert.equal(mockSpawn.mock.calls.length,
|
|
114
|
+
const session1 = manager.createSession('proj-1', 'Project', 80, 24);
|
|
115
|
+
const session2 = manager.createSession('proj-1', 'Project', 100, 30);
|
|
116
|
+
assert.notEqual(session1.id, session2.id);
|
|
117
|
+
assert.equal(mockSpawn.mock.calls.length, 2); // auto-launch for each session
|
|
111
118
|
});
|
|
112
119
|
|
|
113
|
-
it('creates separate sessions for different
|
|
120
|
+
it('creates separate sessions for different projects', () => {
|
|
114
121
|
const mockLog = mock.fn();
|
|
115
|
-
const mockSpawn = mock.fn();
|
|
116
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
122
|
+
const mockSpawn = mock.fn(() => ({ onData: mock.fn(), onExit: mock.fn(), write: mock.fn(), resize: mock.fn(), kill: mock.fn() }));
|
|
123
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
117
124
|
|
|
118
|
-
const session1 = manager.createSession('
|
|
119
|
-
const session2 = manager.createSession('
|
|
120
|
-
assert.notEqual(session1, session2);
|
|
125
|
+
const session1 = manager.createSession('proj-1', 'Project One', 80, 24);
|
|
126
|
+
const session2 = manager.createSession('proj-2', 'Project Two', 80, 24);
|
|
127
|
+
assert.notEqual(session1.id, session2.id);
|
|
121
128
|
});
|
|
122
129
|
|
|
123
|
-
it('getSession returns undefined for unknown
|
|
130
|
+
it('getSession returns undefined for unknown session id', () => {
|
|
124
131
|
const mockLog = mock.fn();
|
|
125
132
|
const mockSpawn = mock.fn();
|
|
126
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
133
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
127
134
|
|
|
128
|
-
assert.equal(manager.getSession('
|
|
135
|
+
assert.equal(manager.getSession('term_unknown'), undefined);
|
|
129
136
|
});
|
|
130
137
|
|
|
131
138
|
it('destroySession removes the session and kills running PTY', () => {
|
|
@@ -138,15 +145,14 @@ describe('PtySessionManager session lifecycle', () => {
|
|
|
138
145
|
kill: mock.fn(),
|
|
139
146
|
};
|
|
140
147
|
const mockSpawn = mock.fn(() => mockPty);
|
|
141
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
148
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
142
149
|
|
|
143
|
-
manager.createSession('
|
|
144
|
-
//
|
|
145
|
-
manager.
|
|
146
|
-
assert.ok(manager.getSession('user-1'));
|
|
150
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
151
|
+
// session.pty is already set by auto-launch
|
|
152
|
+
assert.ok(manager.getSession(session.id));
|
|
147
153
|
|
|
148
|
-
manager.destroySession(
|
|
149
|
-
assert.equal(manager.getSession(
|
|
154
|
+
manager.destroySession(session.id);
|
|
155
|
+
assert.equal(manager.getSession(session.id), undefined);
|
|
150
156
|
assert.equal(mockPty.kill.mock.calls.length, 1);
|
|
151
157
|
});
|
|
152
158
|
|
|
@@ -169,17 +175,15 @@ describe('PtySessionManager session lifecycle', () => {
|
|
|
169
175
|
const ptys = [mockPty1, mockPty2];
|
|
170
176
|
let idx = 0;
|
|
171
177
|
const mockSpawn = mock.fn(() => ptys[idx++]);
|
|
172
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
178
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
173
179
|
|
|
174
|
-
manager.createSession('
|
|
175
|
-
manager.createSession('
|
|
176
|
-
//
|
|
177
|
-
manager.writeToSession('user-1', 'claude\r');
|
|
178
|
-
manager.writeToSession('user-2', 'claude\r');
|
|
180
|
+
const s1 = manager.createSession('proj-1', 'Project One', 80, 24);
|
|
181
|
+
const s2 = manager.createSession('proj-2', 'Project Two', 80, 24);
|
|
182
|
+
// PTYs are already running via auto-launch
|
|
179
183
|
|
|
180
184
|
manager.destroyAll();
|
|
181
|
-
assert.equal(manager.getSession(
|
|
182
|
-
assert.equal(manager.getSession(
|
|
185
|
+
assert.equal(manager.getSession(s1.id), undefined);
|
|
186
|
+
assert.equal(manager.getSession(s2.id), undefined);
|
|
183
187
|
});
|
|
184
188
|
|
|
185
189
|
it('resizeSession updates stored dimensions and forwards to running PTY', () => {
|
|
@@ -192,18 +196,18 @@ describe('PtySessionManager session lifecycle', () => {
|
|
|
192
196
|
kill: mock.fn(),
|
|
193
197
|
};
|
|
194
198
|
const mockSpawn = mock.fn(() => mockPty);
|
|
195
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
199
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
196
200
|
|
|
197
|
-
manager.createSession('
|
|
198
|
-
|
|
199
|
-
manager.resizeSession(
|
|
201
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
202
|
+
// PTY already running via auto-launch
|
|
203
|
+
manager.resizeSession(session.id, 120, 40);
|
|
200
204
|
|
|
201
205
|
assert.equal(mockPty.resize.mock.calls.length, 1);
|
|
202
206
|
assert.deepEqual(mockPty.resize.mock.calls[0].arguments, [120, 40]);
|
|
203
207
|
|
|
204
|
-
const
|
|
205
|
-
assert.equal(
|
|
206
|
-
assert.equal(
|
|
208
|
+
const updated = manager.getSession(session.id);
|
|
209
|
+
assert.equal(updated?.cols, 120);
|
|
210
|
+
assert.equal(updated?.rows, 40);
|
|
207
211
|
});
|
|
208
212
|
});
|
|
209
213
|
|
|
@@ -212,20 +216,28 @@ describe('PtySessionManager session lifecycle', () => {
|
|
|
212
216
|
describe('Command execution — whitelist enforced on every command', () => {
|
|
213
217
|
it('rejects disallowed command typed character-by-character', () => {
|
|
214
218
|
const mockLog = mock.fn();
|
|
215
|
-
|
|
216
|
-
const
|
|
219
|
+
let exitHandler: (info: { exitCode: number }) => void = () => {};
|
|
220
|
+
const mockPty = {
|
|
221
|
+
onData: mock.fn(),
|
|
222
|
+
onExit: mock.fn((cb: Function) => { exitHandler = cb as any; }),
|
|
223
|
+
write: mock.fn(), resize: mock.fn(), kill: mock.fn(),
|
|
224
|
+
};
|
|
225
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
226
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
217
227
|
const output: string[] = [];
|
|
218
228
|
|
|
219
|
-
manager.createSession('
|
|
220
|
-
|
|
229
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
230
|
+
// Let auto-launch exit to enter idle mode
|
|
231
|
+
exitHandler({ exitCode: 0 });
|
|
232
|
+
manager.addListener(session.id, (data) => output.push(data));
|
|
221
233
|
|
|
222
|
-
// Type "ls" and press Enter
|
|
223
|
-
manager.writeToSession(
|
|
224
|
-
manager.writeToSession(
|
|
225
|
-
manager.writeToSession(
|
|
234
|
+
// Type "ls" and press Enter (in idle mode)
|
|
235
|
+
manager.writeToSession(session.id, 'l');
|
|
236
|
+
manager.writeToSession(session.id, 's');
|
|
237
|
+
manager.writeToSession(session.id, '\r');
|
|
226
238
|
|
|
227
|
-
//
|
|
228
|
-
assert.equal(mockSpawn.mock.calls.length,
|
|
239
|
+
// Only auto-launch was spawned, no new PTY for disallowed command
|
|
240
|
+
assert.equal(mockSpawn.mock.calls.length, 1);
|
|
229
241
|
|
|
230
242
|
// Output should contain the error message
|
|
231
243
|
const fullOutput = output.join('');
|
|
@@ -234,27 +246,31 @@ describe('Command execution — whitelist enforced on every command', () => {
|
|
|
234
246
|
|
|
235
247
|
it('allows whitelisted command and spawns PTY', () => {
|
|
236
248
|
const mockLog = mock.fn();
|
|
249
|
+
let exitHandler: (info: { exitCode: number }) => void = () => {};
|
|
237
250
|
const mockPty = {
|
|
238
251
|
onData: mock.fn(),
|
|
239
|
-
onExit: mock.fn(),
|
|
252
|
+
onExit: mock.fn((cb: Function) => { exitHandler = cb as any; }),
|
|
240
253
|
write: mock.fn(),
|
|
241
254
|
resize: mock.fn(),
|
|
242
255
|
kill: mock.fn(),
|
|
243
256
|
};
|
|
244
257
|
const mockSpawn = mock.fn(() => mockPty);
|
|
245
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
258
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
259
|
+
|
|
260
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
261
|
+
// Let auto-launch exit to enter idle mode
|
|
262
|
+
exitHandler({ exitCode: 0 });
|
|
246
263
|
|
|
247
|
-
manager.
|
|
248
|
-
manager.writeToSession('user-1', 'claude login\r');
|
|
264
|
+
manager.writeToSession(session.id, 'claude login\r');
|
|
249
265
|
|
|
250
|
-
//
|
|
251
|
-
assert.equal(mockSpawn.mock.calls.length,
|
|
252
|
-
assert.equal(mockSpawn.mock.calls[
|
|
253
|
-
assert.deepEqual(mockSpawn.mock.calls[
|
|
266
|
+
// Auto-launch (call 0) + user command (call 1)
|
|
267
|
+
assert.equal(mockSpawn.mock.calls.length, 2);
|
|
268
|
+
assert.equal(mockSpawn.mock.calls[1].arguments[0], 'claude');
|
|
269
|
+
assert.deepEqual(mockSpawn.mock.calls[1].arguments[1], ['login']);
|
|
254
270
|
|
|
255
271
|
// Session should be in running state
|
|
256
|
-
const
|
|
257
|
-
assert.equal(
|
|
272
|
+
const updated = manager.getSession(session.id);
|
|
273
|
+
assert.equal(updated?.state, 'running');
|
|
258
274
|
});
|
|
259
275
|
|
|
260
276
|
it('forwards raw input to running PTY process', () => {
|
|
@@ -267,15 +283,13 @@ describe('Command execution — whitelist enforced on every command', () => {
|
|
|
267
283
|
kill: mock.fn(),
|
|
268
284
|
};
|
|
269
285
|
const mockSpawn = mock.fn(() => mockPty);
|
|
270
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
286
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
271
287
|
|
|
272
|
-
manager.createSession('
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
assert.equal(mockPty.write.mock.calls.length, 1);
|
|
278
|
-
assert.equal(mockPty.write.mock.calls[0].arguments[0], 'hello');
|
|
288
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
289
|
+
// Session is already running via auto-launch — send input directly
|
|
290
|
+
manager.writeToSession(session.id, 'hello');
|
|
291
|
+
const lastWrite = mockPty.write.mock.calls[mockPty.write.mock.calls.length - 1];
|
|
292
|
+
assert.equal(lastWrite.arguments[0], 'hello');
|
|
279
293
|
});
|
|
280
294
|
|
|
281
295
|
it('returns to idle state and shows prompt when command exits', () => {
|
|
@@ -291,19 +305,19 @@ describe('Command execution — whitelist enforced on every command', () => {
|
|
|
291
305
|
kill: mock.fn(),
|
|
292
306
|
};
|
|
293
307
|
const mockSpawn = mock.fn(() => mockPty);
|
|
294
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
308
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
295
309
|
const output: string[] = [];
|
|
296
310
|
|
|
297
|
-
manager.createSession('
|
|
298
|
-
manager.addListener(
|
|
299
|
-
manager.writeToSession(
|
|
311
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
312
|
+
manager.addListener(session.id, (data) => output.push(data));
|
|
313
|
+
manager.writeToSession(session.id, 'claude --version\r');
|
|
300
314
|
|
|
301
315
|
// Simulate command exit
|
|
302
316
|
exitHandler({ exitCode: 0 });
|
|
303
317
|
|
|
304
|
-
const
|
|
305
|
-
assert.equal(
|
|
306
|
-
assert.equal(
|
|
318
|
+
const updated = manager.getSession(session.id);
|
|
319
|
+
assert.equal(updated?.state, 'idle');
|
|
320
|
+
assert.equal(updated?.pty, null);
|
|
307
321
|
|
|
308
322
|
// Should show prompt again
|
|
309
323
|
const fullOutput = output.join('');
|
|
@@ -323,95 +337,109 @@ describe('Command execution — whitelist enforced on every command', () => {
|
|
|
323
337
|
kill: mock.fn(),
|
|
324
338
|
};
|
|
325
339
|
const mockSpawn = mock.fn(() => mockPty);
|
|
326
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
340
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
327
341
|
const output: string[] = [];
|
|
328
342
|
|
|
329
|
-
manager.createSession('
|
|
330
|
-
manager.addListener(
|
|
343
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
344
|
+
manager.addListener(session.id, (data) => output.push(data));
|
|
331
345
|
|
|
332
|
-
//
|
|
333
|
-
manager.writeToSession('user-1', 'claude --version\r');
|
|
346
|
+
// Let auto-launch exit to enter idle mode
|
|
334
347
|
exitHandler({ exitCode: 0 });
|
|
335
348
|
|
|
336
349
|
// Clear output to check next command
|
|
337
350
|
output.length = 0;
|
|
338
351
|
|
|
339
352
|
// Try to execute a disallowed command after returning to idle
|
|
340
|
-
manager.writeToSession(
|
|
353
|
+
manager.writeToSession(session.id, 'rm -rf /\r');
|
|
341
354
|
|
|
342
|
-
// Should NOT spawn a new PTY
|
|
343
|
-
assert.equal(mockSpawn.mock.calls.length, 1);
|
|
355
|
+
// Should NOT spawn a new PTY beyond the initial auto-launch
|
|
356
|
+
assert.equal(mockSpawn.mock.calls.length, 1);
|
|
344
357
|
const fullOutput = output.join('');
|
|
345
358
|
assert.ok(fullOutput.includes('not allowed'));
|
|
346
359
|
});
|
|
347
360
|
|
|
348
361
|
it('handles backspace in idle mode', () => {
|
|
349
362
|
const mockLog = mock.fn();
|
|
363
|
+
let exitHandler: (info: { exitCode: number }) => void = () => {};
|
|
350
364
|
const mockPty = {
|
|
351
365
|
onData: mock.fn(),
|
|
352
|
-
onExit: mock.fn(),
|
|
366
|
+
onExit: mock.fn((cb: Function) => { exitHandler = cb as any; }),
|
|
353
367
|
write: mock.fn(),
|
|
354
368
|
resize: mock.fn(),
|
|
355
369
|
kill: mock.fn(),
|
|
356
370
|
};
|
|
357
371
|
const mockSpawn = mock.fn(() => mockPty);
|
|
358
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
372
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
359
373
|
const output: string[] = [];
|
|
360
374
|
|
|
361
|
-
manager.createSession('
|
|
362
|
-
|
|
375
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
376
|
+
// Let auto-launch exit to enter idle mode
|
|
377
|
+
exitHandler({ exitCode: 0 });
|
|
378
|
+
manager.addListener(session.id, (data) => output.push(data));
|
|
363
379
|
|
|
364
380
|
// Type "ls", backspace twice, type "claude"
|
|
365
|
-
manager.writeToSession(
|
|
366
|
-
manager.writeToSession(
|
|
367
|
-
manager.writeToSession(
|
|
381
|
+
manager.writeToSession(session.id, 'ls');
|
|
382
|
+
manager.writeToSession(session.id, '\x7f\x7f'); // two backspaces
|
|
383
|
+
manager.writeToSession(session.id, 'claude\r');
|
|
368
384
|
|
|
369
|
-
//
|
|
370
|
-
assert.equal(mockSpawn.mock.calls.length,
|
|
371
|
-
assert.equal(mockSpawn.mock.calls[
|
|
385
|
+
// auto-launch (call 0) + user's claude command (call 1)
|
|
386
|
+
assert.equal(mockSpawn.mock.calls.length, 2);
|
|
387
|
+
assert.equal(mockSpawn.mock.calls[1].arguments[0], 'claude');
|
|
388
|
+
assert.deepEqual(mockSpawn.mock.calls[1].arguments[1], []);
|
|
372
389
|
});
|
|
373
390
|
|
|
374
391
|
it('handles Ctrl+C in idle mode (clears input)', () => {
|
|
375
392
|
const mockLog = mock.fn();
|
|
393
|
+
let exitHandler: (info: { exitCode: number }) => void = () => {};
|
|
376
394
|
const mockPty = {
|
|
377
395
|
onData: mock.fn(),
|
|
378
|
-
onExit: mock.fn(),
|
|
396
|
+
onExit: mock.fn((cb: Function) => { exitHandler = cb as any; }),
|
|
379
397
|
write: mock.fn(),
|
|
380
398
|
resize: mock.fn(),
|
|
381
399
|
kill: mock.fn(),
|
|
382
400
|
};
|
|
383
401
|
const mockSpawn = mock.fn(() => mockPty);
|
|
384
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
402
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
385
403
|
const output: string[] = [];
|
|
386
404
|
|
|
387
|
-
manager.createSession('
|
|
388
|
-
|
|
405
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
406
|
+
// Let auto-launch exit to enter idle mode
|
|
407
|
+
exitHandler({ exitCode: 0 });
|
|
408
|
+
manager.addListener(session.id, (data) => output.push(data));
|
|
389
409
|
|
|
390
410
|
// Type "rm" then Ctrl+C
|
|
391
|
-
manager.writeToSession(
|
|
392
|
-
manager.writeToSession(
|
|
411
|
+
manager.writeToSession(session.id, 'rm');
|
|
412
|
+
manager.writeToSession(session.id, '\x03');
|
|
393
413
|
|
|
394
414
|
// Then type a valid command
|
|
395
|
-
manager.writeToSession(
|
|
415
|
+
manager.writeToSession(session.id, 'claude\r');
|
|
396
416
|
|
|
397
|
-
//
|
|
398
|
-
assert.equal(mockSpawn.mock.calls.length,
|
|
399
|
-
assert.equal(mockSpawn.mock.calls[
|
|
417
|
+
// auto-launch (call 0) + user's claude command (call 1), Ctrl+C cleared "rm"
|
|
418
|
+
assert.equal(mockSpawn.mock.calls.length, 2);
|
|
419
|
+
assert.equal(mockSpawn.mock.calls[1].arguments[0], 'claude');
|
|
400
420
|
});
|
|
401
421
|
|
|
402
422
|
it('handles empty Enter (shows prompt again)', () => {
|
|
403
423
|
const mockLog = mock.fn();
|
|
404
|
-
|
|
405
|
-
const
|
|
424
|
+
let exitHandler: (info: { exitCode: number }) => void = () => {};
|
|
425
|
+
const mockPty = {
|
|
426
|
+
onData: mock.fn(),
|
|
427
|
+
onExit: mock.fn((cb: Function) => { exitHandler = cb as any; }),
|
|
428
|
+
write: mock.fn(), resize: mock.fn(), kill: mock.fn(),
|
|
429
|
+
};
|
|
430
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
431
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
406
432
|
const output: string[] = [];
|
|
407
433
|
|
|
408
|
-
manager.createSession('
|
|
409
|
-
|
|
434
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
435
|
+
// Let auto-launch exit to enter idle mode
|
|
436
|
+
exitHandler({ exitCode: 0 });
|
|
437
|
+
manager.addListener(session.id, (data) => output.push(data));
|
|
410
438
|
|
|
411
|
-
manager.writeToSession(
|
|
439
|
+
manager.writeToSession(session.id, '\r');
|
|
412
440
|
|
|
413
|
-
// No spawn
|
|
414
|
-
assert.equal(mockSpawn.mock.calls.length,
|
|
441
|
+
// No additional spawn beyond auto-launch
|
|
442
|
+
assert.equal(mockSpawn.mock.calls.length, 1);
|
|
415
443
|
|
|
416
444
|
// Should show prompt
|
|
417
445
|
const fullOutput = output.join('');
|
|
@@ -421,17 +449,17 @@ describe('Command execution — whitelist enforced on every command', () => {
|
|
|
421
449
|
it('handles spawn failure gracefully', () => {
|
|
422
450
|
const mockLog = mock.fn();
|
|
423
451
|
const mockSpawn = mock.fn(() => { throw new Error('spawn failed'); });
|
|
424
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
452
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
425
453
|
const output: string[] = [];
|
|
426
454
|
|
|
427
|
-
manager.createSession('
|
|
428
|
-
manager.addListener(
|
|
455
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
456
|
+
manager.addListener(session.id, (data) => output.push(data));
|
|
429
457
|
|
|
430
|
-
manager.writeToSession(
|
|
458
|
+
manager.writeToSession(session.id, 'claude\r');
|
|
431
459
|
|
|
432
460
|
// Session should remain idle
|
|
433
|
-
const
|
|
434
|
-
assert.equal(
|
|
461
|
+
const updated = manager.getSession(session.id);
|
|
462
|
+
assert.equal(updated?.state, 'idle');
|
|
435
463
|
|
|
436
464
|
// Should show error and prompt
|
|
437
465
|
const fullOutput = output.join('');
|
|
@@ -443,16 +471,17 @@ describe('Command execution — whitelist enforced on every command', () => {
|
|
|
443
471
|
// --- Output buffer (dec_030 — session persistence) ---
|
|
444
472
|
|
|
445
473
|
describe('PTY output buffering (dec_030)', () => {
|
|
446
|
-
it('buffers
|
|
474
|
+
it('buffers welcome message on session creation', () => {
|
|
447
475
|
const mockLog = mock.fn();
|
|
448
|
-
const
|
|
449
|
-
const
|
|
476
|
+
const mockPty = { onData: mock.fn(), onExit: mock.fn(), write: mock.fn(), resize: mock.fn(), kill: mock.fn() };
|
|
477
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
478
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
450
479
|
|
|
451
|
-
manager.createSession('
|
|
480
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
452
481
|
|
|
453
|
-
const buffered = manager.getBufferedOutput(
|
|
482
|
+
const buffered = manager.getBufferedOutput(session.id);
|
|
454
483
|
assert.ok(buffered.includes('Restricted terminal'));
|
|
455
|
-
|
|
484
|
+
// prompt appears after auto-launched claude exits, not before
|
|
456
485
|
});
|
|
457
486
|
|
|
458
487
|
it('buffers PTY command output for reconnection', () => {
|
|
@@ -466,53 +495,63 @@ describe('PTY output buffering (dec_030)', () => {
|
|
|
466
495
|
kill: mock.fn(),
|
|
467
496
|
};
|
|
468
497
|
const mockSpawn = mock.fn(() => mockPty);
|
|
469
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
498
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
470
499
|
|
|
471
|
-
manager.createSession('
|
|
472
|
-
|
|
500
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
501
|
+
// dataHandler is captured from the auto-launched claude PTY
|
|
473
502
|
|
|
474
503
|
assert.ok(dataHandler);
|
|
475
504
|
dataHandler!('Claude v1.0.0');
|
|
476
505
|
|
|
477
|
-
const buffered = manager.getBufferedOutput(
|
|
506
|
+
const buffered = manager.getBufferedOutput(session.id);
|
|
478
507
|
assert.ok(buffered.includes('Claude v1.0.0'));
|
|
479
508
|
});
|
|
480
509
|
|
|
481
|
-
it('returns empty string for unknown
|
|
510
|
+
it('returns empty string for unknown session id', () => {
|
|
482
511
|
const mockLog = mock.fn();
|
|
483
512
|
const mockSpawn = mock.fn();
|
|
484
|
-
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
513
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
485
514
|
|
|
486
|
-
assert.equal(manager.getBufferedOutput('
|
|
515
|
+
assert.equal(manager.getBufferedOutput('term_unknown'), '');
|
|
487
516
|
});
|
|
488
517
|
|
|
489
|
-
it('notifies listeners when output is produced', () => {
|
|
518
|
+
it('notifies listeners when output is produced in idle mode', () => {
|
|
490
519
|
const mockLog = mock.fn();
|
|
491
|
-
|
|
492
|
-
const
|
|
520
|
+
let exitHandler: (info: { exitCode: number }) => void = () => {};
|
|
521
|
+
const mockPty = {
|
|
522
|
+
onData: mock.fn(),
|
|
523
|
+
onExit: mock.fn((cb: Function) => { exitHandler = cb as any; }),
|
|
524
|
+
write: mock.fn(), resize: mock.fn(), kill: mock.fn(),
|
|
525
|
+
};
|
|
526
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
527
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
528
|
+
|
|
529
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
530
|
+
// Let auto-launch exit to enter idle mode
|
|
531
|
+
exitHandler({ exitCode: 0 });
|
|
493
532
|
|
|
494
|
-
manager.createSession('user-1', 80, 24);
|
|
495
533
|
const listener = mock.fn();
|
|
496
|
-
manager.addListener(
|
|
534
|
+
manager.addListener(session.id, listener);
|
|
497
535
|
|
|
498
|
-
// Typing echoes characters
|
|
499
|
-
manager.writeToSession(
|
|
536
|
+
// Typing in idle mode echoes characters to listeners
|
|
537
|
+
manager.writeToSession(session.id, 'c');
|
|
500
538
|
assert.ok(listener.mock.calls.length > 0);
|
|
501
539
|
assert.equal(listener.mock.calls[listener.mock.calls.length - 1].arguments[0], 'c');
|
|
502
540
|
});
|
|
503
541
|
|
|
504
542
|
it('removeListener stops notifications', () => {
|
|
505
543
|
const mockLog = mock.fn();
|
|
506
|
-
const
|
|
507
|
-
const
|
|
544
|
+
const mockPty = { onData: mock.fn(), onExit: mock.fn(), write: mock.fn(), resize: mock.fn(), kill: mock.fn() };
|
|
545
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
546
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog, projectRoot: '/test' });
|
|
508
547
|
|
|
509
|
-
manager.createSession('
|
|
548
|
+
const session = manager.createSession('proj-1', 'Project', 80, 24);
|
|
510
549
|
const listener = mock.fn();
|
|
511
|
-
manager.addListener(
|
|
512
|
-
manager.removeListener(
|
|
550
|
+
manager.addListener(session.id, listener);
|
|
551
|
+
manager.removeListener(session.id, listener);
|
|
513
552
|
|
|
514
553
|
const callsBefore = listener.mock.calls.length;
|
|
515
|
-
manager.writeToSession(
|
|
554
|
+
manager.writeToSession(session.id, 'x'); // forwarded to running PTY, no echo
|
|
516
555
|
assert.equal(listener.mock.calls.length, callsBefore);
|
|
517
556
|
});
|
|
518
557
|
});
|