• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

apowers313 / aiforge / 21002763057

14 Jan 2026 05:00PM UTC coverage: 82.93% (-1.8%) from 84.765%
21002763057

push

github

apowers313
chore: delint

993 of 1165 branches covered (85.24%)

Branch coverage included in aggregate %.

5206 of 6310 relevant lines covered (82.5%)

15.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

45.7
/src/server/services/shell/ShellService.ts
1
/**
2
 * ShellService - Shell business logic
3
 */
4
import { randomUUID } from 'node:crypto';
1✔
5
import type { Shell, ShellType } from '@shared/types/index.js';
6
import type { ShellStore } from '../../storage/stores/ShellStore.js';
7
import type { ProjectStore } from '../../storage/stores/ProjectStore.js';
8
import { PtyPool, type ShellStoreInterface } from '../pty/index.js';
9
import { getSocketPath } from '../pty/daemon/protocol.js';
1✔
10

11
export interface ShellServiceOptions {
12
  shellStore: ShellStore;
13
  projectStore: ProjectStore;
14
  ptyPool?: PtyPool;
15
}
16

17
export class ShellService {
1✔
18
  private readonly shellStore: ShellStore;
8✔
19
  private readonly projectStore: ProjectStore;
8✔
20
  private readonly ptyPool: PtyPool | null;
8✔
21
  private readonly lastOutputTimers = new Map<string, ReturnType<typeof setTimeout>>();
8✔
22
  private readonly lastOutputDebounceMs = 1000; // Debounce lastOutputAt updates by 1 second
8✔
23

24
  constructor(options: ShellServiceOptions) {
8✔
25
    this.shellStore = options.shellStore;
8✔
26
    this.projectStore = options.projectStore;
8✔
27
    this.ptyPool = options.ptyPool ?? null;
8✔
28

29
    // Set up event handlers if ptyPool is provided
30
    if (this.ptyPool) {
8!
31
      this.ptyPool.on('session:exited', (shellId: string, exitCode: number) => {
×
32
        void this._handleSessionExit(shellId, exitCode);
×
33
      });
×
34

35
      // Track lastActivityAt for AI shells (debounced to reduce disk writes)
36
      // Listen to both output and input activity
37
      this.ptyPool.on('session:activity', (shellId: string) => {
×
38
        void this._handleSessionActivity(shellId);
×
39
      });
×
40
    }
×
41
  }
8✔
42

43
  /**
44
   * Handle PTY session activity (update lastActivityAt for AI shells)
45
   */
46
  private async _handleSessionActivity(shellId: string): Promise<void> {
8✔
47
    // Check if shell is an AI shell (only track lastActivityAt for AI shells)
48
    const shell = await this.shellStore.getById(shellId);
×
49
    if (shell?.type !== 'ai') {
×
50
      return;
×
51
    }
×
52

53
    // Debounce the update to reduce disk writes
54
    const existingTimer = this.lastOutputTimers.get(shellId);
×
55
    if (existingTimer) {
×
56
      clearTimeout(existingTimer);
×
57
    }
×
58

59
    const timer = setTimeout(() => {
×
60
      this.lastOutputTimers.delete(shellId);
×
61
      void this.shellStore.update(shellId, {
×
62
        lastActivityAt: new Date().toISOString(),
×
63
      });
×
64
    }, this.lastOutputDebounceMs);
×
65

66
    this.lastOutputTimers.set(shellId, timer);
×
67
  }
×
68

69
  /**
70
   * Handle PTY session exit
71
   */
72
  private async _handleSessionExit(shellId: string, _exitCode: number): Promise<void> {
8✔
73
    await this.shellStore.update(shellId, {
×
74
      status: 'inactive',
×
75
      pid: null,
×
76
    });
×
77
  }
×
78

79
  /**
80
   * Get all shells for a project
81
   */
82
  async getByProjectId(projectId: string): Promise<Shell[]> {
8✔
83
    return this.shellStore.getByProjectId(projectId);
1✔
84
  }
1✔
85

86
  /**
87
   * Get a shell by ID
88
   */
89
  async getById(id: string): Promise<Shell | null> {
8✔
90
    return this.shellStore.getById(id);
×
91
  }
×
92

93
  /**
94
   * Create a new shell for a project
95
   */
96
  async create(projectId: string, name?: string, type: ShellType = 'bash'): Promise<Shell> {
8✔
97
    // Verify project exists
98
    const project = await this.projectStore.getById(projectId);
7✔
99
    if (!project) {
7✔
100
      throw new Error('Project not found');
1✔
101
    }
1✔
102

103
    // Auto-generate name if not provided
104
    let shellName = name;
6✔
105
    if (!shellName) {
7✔
106
      const shellNumber = await this.shellStore.getNextShellNumber();
1✔
107
      const prefix = type === 'ai' ? 'ai' : 'shell';
1!
108
      shellName = `${prefix}-${String(shellNumber)}`;
1✔
109
    }
1✔
110

111
    const now = new Date().toISOString();
6✔
112
    const shell: Shell = {
6✔
113
      id: randomUUID(),
6✔
114
      projectId,
6✔
115
      name: shellName,
6✔
116
      cwd: project.path,
6✔
117
      status: 'inactive',
6✔
118
      type,
6✔
119
      pid: null,
6✔
120
      socketPath: null,
6✔
121
      lastActivityAt: null,
6✔
122
      done: false,
6✔
123
      createdAt: now,
6✔
124
      updatedAt: now,
6✔
125
    };
6✔
126

127
    await this.shellStore.create(shell);
6✔
128
    return shell;
6✔
129
  }
7✔
130

131
  /**
132
   * Update a shell's properties
133
   */
134
  async update(id: string, updates: Partial<Pick<Shell, 'name' | 'status' | 'pid' | 'cwd' | 'lastActivityAt' | 'done' | 'socketPath'>>): Promise<Shell | null> {
8✔
135
    return this.shellStore.update(id, updates);
2✔
136
  }
2✔
137

138
  /**
139
   * Delete a shell
140
   */
141
  async delete(id: string): Promise<boolean> {
8✔
142
    // Kill the PTY session if running
143
    if (this.ptyPool) {
2!
144
      this.ptyPool.kill(id);
×
145
    }
×
146
    return this.shellStore.delete(id);
2✔
147
  }
2✔
148

149
  /**
150
   * Start a shell (spawn PTY process)
151
   */
152
  async start(shellId: string): Promise<Shell> {
8✔
153
    if (!this.ptyPool) {
7✔
154
      throw new Error('PTY pool not configured');
7✔
155
    }
7!
156

157
    const shell = await this.shellStore.getById(shellId);
×
158
    if (!shell) {
×
159
      throw new Error('Shell not found');
×
160
    }
×
161

162
    // Check if already running in memory
163
    if (this.ptyPool.get(shellId)) {
×
164
      return shell;
×
165
    }
×
166

167
    // In daemon mode, check if a daemon is already running we can reconnect to
168
    if (this.ptyPool.usePersistentDaemons && shell.socketPath) {
7!
169
      const client = await this.ptyPool.attach(shellId, shell.cwd);
×
170
      if (client) {
×
171
        // Successfully reconnected to existing daemon
172
        return shell;
×
173
      }
×
174
      // Socket was stale, clear it and spawn new daemon
175
      await this.shellStore.update(shellId, { socketPath: null });
×
176
    }
×
177

178
    // Load scrollback from disk first (for replay on client attach)
179
    await this.ptyPool.loadScrollback(shellId);
×
180

181
    // Add restart separator to scrollback if there's existing content
182
    const scrollbackStore = this.ptyPool.manager.scrollbackStore;
×
183
    if (scrollbackStore) {
×
184
      const existingEntries = scrollbackStore.getFromMemory(shellId);
×
185
      if (existingEntries.length > 0) {
×
186
        scrollbackStore.append(shellId, 'output', '\r\n\r\n--- shell restarted ---\r\n\r\n');
×
187
      }
×
188
    }
×
189

190
    // Spawn PTY session (use async for daemon mode)
191
    if (this.ptyPool.usePersistentDaemons) {
×
192
      await this.ptyPool.spawnAsync(shellId, {
×
193
        cwd: shell.cwd,
×
194
      });
×
195

196
      // Update shell status with socket path
197
      const socketPath = getSocketPath(shellId);
×
198
      const updated = await this.shellStore.update(shellId, {
×
199
        status: 'active',
×
200
        pid: null, // Daemon mode doesn't track PID directly
×
201
        socketPath,
×
202
      });
×
203

204
      return updated ?? shell;
×
205
    }
×
206

207
    // Legacy mode: spawn directly
208
    const session = this.ptyPool.spawn(shellId, {
×
209
      cwd: shell.cwd,
×
210
    });
×
211

212
    // Update shell status
213
    const updated = await this.shellStore.update(shellId, {
×
214
      status: 'active',
×
215
      pid: session.pid,
×
216
    });
×
217

218
    return updated ?? shell;
×
219
  }
7✔
220

221
  /**
222
   * Stop a shell (kill PTY process)
223
   */
224
  async stop(shellId: string): Promise<Shell | null> {
8✔
225
    if (!this.ptyPool) {
1✔
226
      throw new Error('PTY pool not configured');
1✔
227
    }
1!
228

229
    this.ptyPool.kill(shellId);
×
230

231
    return this.shellStore.update(shellId, {
×
232
      status: 'inactive',
×
233
      pid: null,
×
234
    });
×
235
  }
1✔
236

237
  /**
238
   * Get the PTY pool (for WebSocket handler access)
239
   */
240
  getPtyPool(): PtyPool | null {
8✔
241
    return this.ptyPool;
×
242
  }
×
243

244
  /**
245
   * Clean up orphaned sessions
246
   */
247
  async cleanupOrphans(): Promise<void> {
8✔
248
    if (this.ptyPool) {
×
249
      await this.ptyPool.cleanupOrphans(this.shellStore as unknown as ShellStoreInterface);
×
250
    }
×
251
  }
×
252

253
  /**
254
   * Reconnect to all persistent daemon sessions
255
   * Call this on server startup to restore sessions that survived restart
256
   */
257
  async reconnectDaemons(): Promise<number> {
8✔
258
    if (!this.ptyPool) {
×
259
      return 0;
×
260
    }
×
261
    return this.ptyPool.reconnectDaemons(this.shellStore as unknown as ShellStoreInterface);
×
262
  }
×
263

264
  /**
265
   * Check if persistent daemon mode is enabled
266
   */
267
  get usePersistentDaemons(): boolean {
8✔
268
    return this.ptyPool?.usePersistentDaemons ?? false;
×
269
  }
×
270

271
  /**
272
   * Shutdown all PTY sessions
273
   */
274
  shutdown(): void {
8✔
275
    if (this.ptyPool) {
×
276
      this.ptyPool.shutdown();
×
277
    }
×
278
  }
×
279
}
8✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc