(opts: SpawnOptions)
| 152 | private handles = new Map<string, PtyHandle>(); |
| 153 | |
| 154 | spawn(opts: SpawnOptions): {pid: number} { |
| 155 | const existing = this.handles.get(opts.sessionId); |
| 156 | if (existing && !existing.exited && existing.pty) { |
| 157 | throw new Error( |
| 158 | `Session "${opts.sessionId}" is already running — kill it first or attach to it.`, |
| 159 | ); |
| 160 | } |
| 161 | // If there's a stale exited handle, drop it. |
| 162 | if (existing) this.handles.delete(opts.sessionId); |
| 163 | |
| 164 | const env = { |
| 165 | ...process.env, |
| 166 | // Override PATH with the login-shell PATH so per-user tool installs |
| 167 | // (claude, codex, npm global bin) resolve even when the gateway was |
| 168 | // launched by launchd/systemd with a minimal environment. |
| 169 | PATH: LOGIN_PATH || process.env.PATH || '', |
| 170 | ...(opts.env ?? {}), |
| 171 | TERM: 'xterm-256color', |
| 172 | } as Record<string, string>; |
| 173 | |
| 174 | const resolved = resolveCommand(opts.command); |
| 175 | if (!resolved) { |
| 176 | throw new Error( |
| 177 | `command "${opts.command}" not found on PATH (tried login shell). ` + |
| 178 | `Install it or set an absolute path in the agent config.`, |
| 179 | ); |
| 180 | } |
| 181 | |
| 182 | const pty = ptySpawn(resolved, opts.args, { |
| 183 | name: 'xterm-256color', |
| 184 | cols: opts.cols ?? 80, |
| 185 | rows: opts.rows ?? 24, |
| 186 | cwd: opts.cwd, |
| 187 | env, |
| 188 | }); |
| 189 | |
| 190 | const handle: PtyHandle = { |
| 191 | pty, |
| 192 | outputBuffer: Buffer.alloc(0), |
| 193 | pendingUtf8: Buffer.alloc(0), |
| 194 | createdAt: Date.now(), |
| 195 | cancelled: false, |
| 196 | exited: false, |
| 197 | subscribers: new Set(), |
| 198 | exitSubscribers: new Set(), |
| 199 | lastActivityAt: Date.now(), |
| 200 | meta: { |
| 201 | command: opts.command, |
| 202 | args: opts.args, |
| 203 | cwd: opts.cwd, |
| 204 | agent: opts.agent, |
| 205 | }, |
| 206 | }; |
| 207 | |
| 208 | pty.onData((data) => { |
| 209 | if (handle.cancelled) return; |
| 210 | handle.lastActivityAt = Date.now(); |
| 211 | // Append raw bytes to scrollback (for future reconnects) |
no test coverage detected