@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.
Files changed (49) hide show
  1. package/package.json +2 -1
  2. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +231 -0
  3. package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +4 -4
  4. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +49 -2
  5. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +82 -0
  6. package/templates/assistkick-product-system/packages/backend/src/server.ts +19 -6
  7. package/templates/assistkick-product-system/packages/backend/src/services/github_app_service.ts +146 -0
  8. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +69 -2
  9. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +71 -0
  10. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +87 -0
  11. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +194 -0
  12. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -17
  13. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +114 -39
  14. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +28 -14
  15. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +1 -1
  16. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +151 -0
  17. package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +352 -0
  18. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +208 -95
  19. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +17 -1
  20. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +238 -105
  21. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +15 -13
  22. package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +1 -0
  23. package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +4 -0
  24. package/templates/assistkick-product-system/packages/frontend/src/routes/dashboard.tsx +22 -4
  25. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +486 -38
  26. package/templates/assistkick-product-system/packages/shared/db/migrations/0001_vengeful_wallop.sql +1 -0
  27. package/templates/assistkick-product-system/packages/shared/db/migrations/0002_greedy_excalibur.sql +4 -0
  28. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0001_snapshot.json +826 -0
  29. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0002_snapshot.json +854 -0
  30. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +14 -0
  31. package/templates/assistkick-product-system/packages/shared/db/schema.ts +5 -0
  32. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +54 -1
  33. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +25 -0
  34. package/templates/assistkick-product-system/packages/shared/lib/pipeline-state-store.ts +4 -0
  35. package/templates/assistkick-product-system/packages/shared/lib/pipeline.ts +329 -89
  36. package/templates/assistkick-product-system/packages/shared/lib/pipeline_orchestrator.ts +186 -0
  37. package/templates/assistkick-product-system/packages/shared/tools/db_explorer.ts +275 -0
  38. package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +2 -1
  39. package/templates/assistkick-product-system/packages/shared/tools/move_card.ts +3 -2
  40. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
  41. package/templates/assistkick-product-system/tests/kanban.test.ts +1 -1
  42. package/templates/assistkick-product-system/tests/pipeline_stats_all_cards.test.ts +1 -1
  43. package/templates/assistkick-product-system/tests/web_terminal.test.ts +189 -150
  44. package/templates/skills/assistkick-bootstrap/SKILL.md +33 -25
  45. package/templates/skills/assistkick-code-reviewer/SKILL.md +23 -15
  46. package/templates/skills/assistkick-db-explorer/SKILL.md +86 -0
  47. package/templates/skills/assistkick-debugger/SKILL.md +30 -22
  48. package/templates/skills/assistkick-developer/SKILL.md +37 -29
  49. 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('creates a new session in idle state (no shell spawned)', () => {
88
+ it('auto-launches claude on session creation', () => {
89
89
  const mockLog = mock.fn();
90
- const mockSpawn = mock.fn();
91
- const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
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('user-1', 80, 24);
94
+ const session = manager.createSession('proj-1', 'Project One', 80, 24);
94
95
  assert.ok(session);
95
- assert.equal(session.userId, 'user-1');
96
- assert.equal(session.state, 'idle');
97
- assert.equal(session.pty, null);
98
- // No shell spawned on session creation
99
- assert.equal(mockSpawn.mock.calls.length, 0);
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('reuses existing session for same user', () => {
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('user-1', 80, 24);
108
- const session2 = manager.createSession('user-1', 100, 30);
109
- assert.equal(session1, session2);
110
- assert.equal(mockSpawn.mock.calls.length, 0);
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 users', () => {
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('user-1', 80, 24);
119
- const session2 = manager.createSession('user-2', 80, 24);
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 user', () => {
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('unknown'), undefined);
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('user-1', 80, 24);
144
- // Simulate a running command by sending "claude\r"
145
- manager.writeToSession('user-1', 'claude\r');
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('user-1');
149
- assert.equal(manager.getSession('user-1'), undefined);
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('user-1', 80, 24);
175
- manager.createSession('user-2', 80, 24);
176
- // Start commands so PTYs exist
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('user-1'), undefined);
182
- assert.equal(manager.getSession('user-2'), undefined);
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('user-1', 80, 24);
198
- manager.writeToSession('user-1', 'claude\r'); // start a command so PTY exists
199
- manager.resizeSession('user-1', 120, 40);
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 session = manager.getSession('user-1');
205
- assert.equal(session?.cols, 120);
206
- assert.equal(session?.rows, 40);
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
- const mockSpawn = mock.fn();
216
- const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
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('user-1', 80, 24);
220
- manager.addListener('user-1', (data) => output.push(data));
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('user-1', 'l');
224
- manager.writeToSession('user-1', 's');
225
- manager.writeToSession('user-1', '\r');
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
- // No PTY should have been spawned
228
- assert.equal(mockSpawn.mock.calls.length, 0);
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.createSession('user-1', 80, 24);
248
- manager.writeToSession('user-1', 'claude login\r');
264
+ manager.writeToSession(session.id, 'claude login\r');
249
265
 
250
- // PTY should have been spawned with "claude" and ["login"]
251
- assert.equal(mockSpawn.mock.calls.length, 1);
252
- assert.equal(mockSpawn.mock.calls[0].arguments[0], 'claude');
253
- assert.deepEqual(mockSpawn.mock.calls[0].arguments[1], ['login']);
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 session = manager.getSession('user-1');
257
- assert.equal(session?.state, 'running');
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('user-1', 80, 24);
273
- manager.writeToSession('user-1', 'claude\r');
274
-
275
- // Now send input while command is running
276
- manager.writeToSession('user-1', 'hello');
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('user-1', 80, 24);
298
- manager.addListener('user-1', (data) => output.push(data));
299
- manager.writeToSession('user-1', 'claude --version\r');
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 session = manager.getSession('user-1');
305
- assert.equal(session?.state, 'idle');
306
- assert.equal(session?.pty, null);
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('user-1', 80, 24);
330
- manager.addListener('user-1', (data) => output.push(data));
343
+ const session = manager.createSession('proj-1', 'Project', 80, 24);
344
+ manager.addListener(session.id, (data) => output.push(data));
331
345
 
332
- // Execute a valid command
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('user-1', 'rm -rf /\r');
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); // only the first valid command
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('user-1', 80, 24);
362
- manager.addListener('user-1', (data) => output.push(data));
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('user-1', 'ls');
366
- manager.writeToSession('user-1', '\x7f\x7f'); // two backspaces
367
- manager.writeToSession('user-1', 'claude\r');
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
- // Should have spawned claude (not ls)
370
- assert.equal(mockSpawn.mock.calls.length, 1);
371
- assert.equal(mockSpawn.mock.calls[0].arguments[0], 'claude');
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('user-1', 80, 24);
388
- manager.addListener('user-1', (data) => output.push(data));
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('user-1', 'rm');
392
- manager.writeToSession('user-1', '\x03');
411
+ manager.writeToSession(session.id, 'rm');
412
+ manager.writeToSession(session.id, '\x03');
393
413
 
394
414
  // Then type a valid command
395
- manager.writeToSession('user-1', 'claude\r');
415
+ manager.writeToSession(session.id, 'claude\r');
396
416
 
397
- // Should have spawned claude (Ctrl+C cleared "rm")
398
- assert.equal(mockSpawn.mock.calls.length, 1);
399
- assert.equal(mockSpawn.mock.calls[0].arguments[0], 'claude');
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
- const mockSpawn = mock.fn();
405
- const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
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('user-1', 80, 24);
409
- manager.addListener('user-1', (data) => output.push(data));
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('user-1', '\r');
439
+ manager.writeToSession(session.id, '\r');
412
440
 
413
- // No spawn
414
- assert.equal(mockSpawn.mock.calls.length, 0);
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('user-1', 80, 24);
428
- manager.addListener('user-1', (data) => output.push(data));
455
+ const session = manager.createSession('proj-1', 'Project', 80, 24);
456
+ manager.addListener(session.id, (data) => output.push(data));
429
457
 
430
- manager.writeToSession('user-1', 'claude\r');
458
+ manager.writeToSession(session.id, 'claude\r');
431
459
 
432
460
  // Session should remain idle
433
- const session = manager.getSession('user-1');
434
- assert.equal(session?.state, 'idle');
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 output including welcome message and prompt', () => {
474
+ it('buffers welcome message on session creation', () => {
447
475
  const mockLog = mock.fn();
448
- const mockSpawn = mock.fn();
449
- const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
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('user-1', 80, 24);
480
+ const session = manager.createSession('proj-1', 'Project', 80, 24);
452
481
 
453
- const buffered = manager.getBufferedOutput('user-1');
482
+ const buffered = manager.getBufferedOutput(session.id);
454
483
  assert.ok(buffered.includes('Restricted terminal'));
455
- assert.ok(buffered.includes('$'));
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('user-1', 80, 24);
472
- manager.writeToSession('user-1', 'claude --version\r');
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('user-1');
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 user', () => {
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('unknown'), '');
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
- const mockSpawn = mock.fn();
492
- const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
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('user-1', listener);
534
+ manager.addListener(session.id, listener);
497
535
 
498
- // Typing echoes characters
499
- manager.writeToSession('user-1', 'c');
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 mockSpawn = mock.fn();
507
- const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
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('user-1', 80, 24);
548
+ const session = manager.createSession('proj-1', 'Project', 80, 24);
510
549
  const listener = mock.fn();
511
- manager.addListener('user-1', listener);
512
- manager.removeListener('user-1', listener);
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('user-1', 'x');
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
  });