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

apowers313 / aiforge / 21152598554

19 Jan 2026 10:00PM UTC coverage: 83.846% (+0.9%) from 82.966%
21152598554

push

github

apowers313
Merge branch 'master' of https://github.com/apowers313/aiforge

1604 of 1876 branches covered (85.5%)

Branch coverage included in aggregate %.

8242 of 9867 relevant lines covered (83.53%)

20.24 hits per line

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

56.55
/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

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

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

23
  // Track last input time per shell for input-aware activity tracking
24
  // Output only counts as activity if there was recent input (within this window)
25
  private readonly lastInputTimes = new Map<string, number>();
12✔
26
  private readonly inputActivityWindowMs = 30000; // 30 seconds - output after input counts as activity
12✔
27

28
  constructor(options: ShellServiceOptions) {
12✔
29
    this.shellStore = options.shellStore;
12✔
30
    this.projectStore = options.projectStore;
12✔
31
    this.ptyPool = options.ptyPool ?? null;
12✔
32

33
    // Set up event handlers if ptyPool is provided
34
    if (this.ptyPool) {
12!
35
      this.ptyPool.on('session:exited', (shellId: string, exitCode: number) => {
×
36
        void this._handleSessionExit(shellId, exitCode);
×
37
      });
×
38

39
      // Track lastActivityAt for AI shells with input-aware logic:
40
      // - Input always counts as activity
41
      // - Output only counts if there was recent input (within 30s)
42
      // This prevents prompt refresh after reconnection from counting as activity
43
      this.ptyPool.on('session:input', (shellId: string) => {
×
44
        this.lastInputTimes.set(shellId, Date.now());
×
45
        void this._handleSessionActivity(shellId);
×
46
      });
×
47

48
      this.ptyPool.on('session:output', (shellId: string) => {
×
49
        // Only count output as activity if there was recent input
50
        const lastInputTime = this.lastInputTimes.get(shellId);
×
51
        if (lastInputTime && Date.now() - lastInputTime < this.inputActivityWindowMs) {
×
52
          void this._handleSessionActivity(shellId);
×
53
        }
×
54
        // Otherwise, output is ignored (likely prompt refresh or other housekeeping)
55
      });
×
56
    }
×
57
  }
12✔
58

59
  /**
60
   * Handle PTY session activity (update lastActivityAt for AI shells)
61
   */
62
  private async _handleSessionActivity(shellId: string): Promise<void> {
12✔
63
    // Check if shell is an AI shell (only track lastActivityAt for AI shells)
64
    const shell = await this.shellStore.getById(shellId);
×
65
    if (shell?.type !== 'ai') {
×
66
      return;
×
67
    }
×
68

69
    // Debounce the update to reduce disk writes
70
    const existingTimer = this.lastActivityTimers.get(shellId);
×
71
    if (existingTimer) {
×
72
      clearTimeout(existingTimer);
×
73
    }
×
74

75
    const timer = setTimeout(() => {
×
76
      this.lastActivityTimers.delete(shellId);
×
77
      void this.shellStore.update(shellId, {
×
78
        lastActivityAt: new Date().toISOString(),
×
79
      });
×
80
    }, this.lastActivityDebounceMs);
×
81

82
    this.lastActivityTimers.set(shellId, timer);
×
83
  }
×
84

85
  /**
86
   * Handle PTY session exit
87
   */
88
  private async _handleSessionExit(shellId: string, _exitCode: number): Promise<void> {
12✔
89
    await this.shellStore.update(shellId, {
×
90
      status: 'inactive',
×
91
      pid: null,
×
92
    });
×
93
  }
×
94

95
  /**
96
   * Get all shells for a project
97
   */
98
  async getByProjectId(projectId: string): Promise<Shell[]> {
12✔
99
    return this.shellStore.getByProjectId(projectId);
1✔
100
  }
1✔
101

102
  /**
103
   * Get a shell by ID
104
   */
105
  async getById(id: string): Promise<Shell | null> {
12✔
106
    return this.shellStore.getById(id);
35✔
107
  }
35✔
108

109
  /**
110
   * Create a new shell for a project
111
   */
112
  async create(projectId: string, name?: string, type: ShellType = 'bash'): Promise<Shell> {
12✔
113
    // Verify project exists
114
    const project = await this.projectStore.getById(projectId);
30✔
115
    if (!project) {
30✔
116
      throw new Error('Project not found');
1✔
117
    }
1✔
118

119
    // Auto-generate name if not provided
120
    let shellName = name;
29✔
121
    if (!shellName) {
30✔
122
      const shellNumber = await this.shellStore.getNextShellNumber();
1✔
123
      const prefix = type === 'ai' ? 'ai' : 'shell';
1!
124
      shellName = `${prefix}-${String(shellNumber)}`;
1✔
125
    }
1✔
126

127
    const now = new Date().toISOString();
29✔
128
    const shell: Shell = {
29✔
129
      id: randomUUID(),
29✔
130
      projectId,
29✔
131
      name: shellName,
29✔
132
      cwd: project.path,
29✔
133
      status: 'inactive',
29✔
134
      type,
29✔
135
      pid: null,
29✔
136
      socketPath: null,
29✔
137
      lastActivityAt: null,
29✔
138
      done: false,
29✔
139
      createdAt: now,
29✔
140
      updatedAt: now,
29✔
141
    };
29✔
142

143
    await this.shellStore.create(shell);
29✔
144
    return shell;
29✔
145
  }
30✔
146

147
  /**
148
   * Update a shell's properties
149
   */
150
  async update(id: string, updates: Partial<Pick<Shell, 'name' | 'status' | 'pid' | 'cwd' | 'lastActivityAt' | 'done' | 'socketPath'>>): Promise<Shell | null> {
12✔
151
    return this.shellStore.update(id, updates);
2✔
152
  }
2✔
153

154
  /**
155
   * Delete a shell
156
   */
157
  async delete(id: string): Promise<boolean> {
12✔
158
    // Kill the PTY session if running
159
    if (this.ptyPool) {
2!
160
      this.ptyPool.kill(id);
×
161
    }
×
162
    return this.shellStore.delete(id);
2✔
163
  }
2✔
164

165
  /**
166
   * Stop a shell (kill PTY process)
167
   */
168
  async stop(shellId: string): Promise<Shell | null> {
12✔
169
    if (!this.ptyPool) {
1✔
170
      throw new Error('PTY pool not configured');
1✔
171
    }
1!
172

173
    this.ptyPool.kill(shellId);
×
174

175
    return this.shellStore.update(shellId, {
×
176
      status: 'inactive',
×
177
      pid: null,
×
178
    });
×
179
  }
1✔
180

181
  /**
182
   * Get the PTY pool (for WebSocket handler access)
183
   */
184
  getPtyPool(): PtyPool | null {
12✔
185
    return this.ptyPool;
×
186
  }
×
187

188
  /**
189
   * Clean up orphaned sessions
190
   */
191
  async cleanupOrphans(): Promise<void> {
12✔
192
    if (this.ptyPool) {
×
193
      await this.ptyPool.cleanupOrphans(this.shellStore as unknown as ShellStoreInterface);
×
194
    }
×
195
  }
×
196

197
  /**
198
   * Reconnect to all persistent daemon sessions
199
   * Call this on server startup to restore sessions that survived restart
200
   */
201
  async reconnectDaemons(): Promise<number> {
12✔
202
    if (!this.ptyPool) {
×
203
      return 0;
×
204
    }
×
205
    return this.ptyPool.reconnectDaemons(this.shellStore as unknown as ShellStoreInterface);
×
206
  }
×
207

208
  /**
209
   * Check if persistent daemon mode is enabled
210
   */
211
  get usePersistentDaemons(): boolean {
12✔
212
    return this.ptyPool?.usePersistentDaemons ?? false;
×
213
  }
×
214

215
  /**
216
   * Shutdown all PTY sessions
217
   */
218
  shutdown(): void {
12✔
219
    if (this.ptyPool) {
×
220
      this.ptyPool.shutdown();
×
221
    }
×
222
  }
×
223
}
12✔
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