mob-coordinator 0.2.1

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.
@@ -0,0 +1,434 @@
1
+ import { EventEmitter } from 'events';
2
+ import { generateInstanceId } from './util/id.js';
3
+ import { HOOK_SILENCE_THRESHOLD_MS } from '../shared/constants.js';
4
+ import { getGitBranch } from './util/platform.js';
5
+ import { fetchJiraStatus } from './jira-client.js';
6
+ import { detectStateFromTerminal } from './terminal-state-detector.js';
7
+ import { createLogger } from './util/logger.js';
8
+ const logger = createLogger('instance-mgr');
9
+ const JIRA_KEY_RE = /([A-Z][A-Z0-9]+-\d+)/;
10
+ function extractJiraKey(branch) {
11
+ if (!branch)
12
+ return null;
13
+ const m = branch.match(JIRA_KEY_RE);
14
+ return m ? m[1] : null;
15
+ }
16
+ export class InstanceManager extends EventEmitter {
17
+ log = logger;
18
+ instances = new Map();
19
+ managedIds = new Set();
20
+ autoNameIds = new Set();
21
+ promptCount = new Map();
22
+ ptyManager;
23
+ discovery;
24
+ sessionStore;
25
+ scrollbackBuffer;
26
+ settingsManager;
27
+ staleTimer = null;
28
+ jiraStatusCache = new Map();
29
+ subscribers = new Map();
30
+ constructor(ptyManager, discovery, sessionStore, scrollbackBuffer, settingsManager) {
31
+ super();
32
+ this.ptyManager = ptyManager;
33
+ this.discovery = discovery;
34
+ this.sessionStore = sessionStore;
35
+ this.scrollbackBuffer = scrollbackBuffer;
36
+ this.settingsManager = settingsManager;
37
+ this.setupListeners();
38
+ this.loadPreviousSessions();
39
+ }
40
+ loadPreviousSessions() {
41
+ const sessions = this.sessionStore.loadAll();
42
+ const toResume = [];
43
+ for (const info of sessions) {
44
+ this.instances.set(info.id, info);
45
+ this.managedIds.add(info.id);
46
+ if (info.autoResume) {
47
+ toResume.push(info);
48
+ }
49
+ }
50
+ // Auto-resume instances that were running when the server shut down
51
+ if (toResume.length > 0) {
52
+ // Defer so the server finishes initializing first
53
+ setTimeout(() => {
54
+ for (const info of toResume) {
55
+ console.log(`[auto-resume] Resuming instance ${info.id} (${info.name}) in ${info.cwd}`);
56
+ this.resume(info.id);
57
+ }
58
+ }, 500);
59
+ }
60
+ }
61
+ setupListeners() {
62
+ this.discovery.on('update', (status) => {
63
+ this.log.info(`discovery update: id=${status.id} state=${status.state} topic=${status.topic || '(none)'}`);
64
+ this.handleHookUpdate(status);
65
+ });
66
+ this.discovery.on('remove', (id) => {
67
+ if (this.instances.has(id) && !this.managedIds.has(id)) {
68
+ this.instances.delete(id);
69
+ this.emit('remove', id);
70
+ }
71
+ });
72
+ this.ptyManager.on('data', (instanceId, data) => {
73
+ this.scrollbackBuffer.append(instanceId, data);
74
+ });
75
+ this.ptyManager.on('exit', (instanceId, exitCode) => {
76
+ const info = this.instances.get(instanceId);
77
+ this.log.info(`PTY exit event: id=${instanceId} exitCode=${exitCode} name="${info?.name}" state=${info?.state}`);
78
+ if (info) {
79
+ info.state = 'stopped';
80
+ info.stoppedAt = Date.now();
81
+ info.lastUpdated = Date.now();
82
+ this.emit('update', info);
83
+ this.scrollbackBuffer.flushAll();
84
+ this.sessionStore.save(info);
85
+ }
86
+ this.emit('pty:exit', instanceId, exitCode);
87
+ });
88
+ }
89
+ /** Compute ticketUrl from a ticket key and current JIRA settings. */
90
+ computeTicketUrl(ticket) {
91
+ if (!ticket)
92
+ return undefined;
93
+ if (ticket.startsWith('http'))
94
+ return ticket;
95
+ const { baseUrl } = this.settingsManager.get().jira;
96
+ if (baseUrl) {
97
+ return `${baseUrl}/browse/${ticket}`;
98
+ }
99
+ return undefined;
100
+ }
101
+ /** Apply ticket derivation (from branch), ticketUrl, and ticketStatus to an InstanceInfo. */
102
+ applyTicketFields(info, explicitTicket, explicitTicketStatus) {
103
+ // Derive ticket from branch if not explicitly set
104
+ if (!info.ticket) {
105
+ const derived = extractJiraKey(info.gitBranch);
106
+ if (derived)
107
+ info.ticket = derived;
108
+ }
109
+ info.ticketUrl = this.computeTicketUrl(info.ticket);
110
+ // Use explicit status if provided
111
+ if (explicitTicketStatus) {
112
+ info.ticketStatus = explicitTicketStatus;
113
+ }
114
+ // Schedule async JIRA status fetch if we have a JIRA key and credentials, and no explicit status
115
+ if (!info.ticketStatus && info.ticket && JIRA_KEY_RE.test(info.ticket)) {
116
+ const jira = this.settingsManager.get().jira;
117
+ if (jira.baseUrl && jira.email && jira.apiToken) {
118
+ this.fetchAndSetJiraStatus(info.id, info.ticket);
119
+ }
120
+ }
121
+ }
122
+ async fetchAndSetJiraStatus(instanceId, ticketKey) {
123
+ // Check cache
124
+ const cached = this.jiraStatusCache.get(ticketKey);
125
+ if (cached && Date.now() - cached.fetchedAt < 60_000) {
126
+ const info = this.instances.get(instanceId);
127
+ if (info && !info.ticketStatus) {
128
+ info.ticketStatus = cached.status;
129
+ this.emit('update', info);
130
+ }
131
+ return;
132
+ }
133
+ const { baseUrl, email, apiToken } = this.settingsManager.get().jira;
134
+ const status = await fetchJiraStatus(baseUrl, email, apiToken, ticketKey);
135
+ if (status) {
136
+ this.jiraStatusCache.set(ticketKey, { status, fetchedAt: Date.now() });
137
+ const info = this.instances.get(instanceId);
138
+ if (info) {
139
+ info.ticketStatus = status;
140
+ this.emit('update', info);
141
+ }
142
+ }
143
+ }
144
+ launch(payload) {
145
+ const id = generateInstanceId();
146
+ const dirName = payload.cwd.split('/').filter(Boolean).pop() || 'instance';
147
+ const now = Date.now();
148
+ const info = {
149
+ id,
150
+ name: payload.autoName ? dirName : (payload.name || id),
151
+ managed: true,
152
+ cwd: payload.cwd,
153
+ gitBranch: getGitBranch(payload.cwd),
154
+ state: 'launching',
155
+ lastUpdated: now,
156
+ createdAt: now,
157
+ model: payload.model,
158
+ permissionMode: payload.permissionMode,
159
+ };
160
+ this.applyTicketFields(info);
161
+ this.instances.set(id, info);
162
+ this.managedIds.add(id);
163
+ if (payload.autoName)
164
+ this.autoNameIds.add(id);
165
+ this.subscribers.set(id, new Set());
166
+ try {
167
+ this.ptyManager.spawn(id, payload.cwd, {
168
+ model: payload.model,
169
+ permissionMode: payload.permissionMode,
170
+ });
171
+ }
172
+ catch (err) {
173
+ info.state = 'stopped';
174
+ this.emit('update', info);
175
+ return info;
176
+ }
177
+ this.emit('update', info);
178
+ this.sessionStore.save(info);
179
+ this.scheduleLaunchTransition(id);
180
+ return info;
181
+ }
182
+ /** Transition launching → running after 3s if nothing else has changed the state. */
183
+ scheduleLaunchTransition(instanceId) {
184
+ setTimeout(() => {
185
+ const info = this.instances.get(instanceId);
186
+ if (info && info.state === 'launching') {
187
+ info.state = 'running';
188
+ info.lastUpdated = Date.now();
189
+ this.emit('update', info);
190
+ }
191
+ }, 3000);
192
+ }
193
+ kill(instanceId) {
194
+ const info = this.instances.get(instanceId);
195
+ this.log.info(`kill() called: id=${instanceId} name="${info?.name}" trace=${new Error().stack?.split('\n').slice(1, 4).map(s => s.trim()).join(' <- ')}`);
196
+ this.ptyManager.kill(instanceId);
197
+ if (info) {
198
+ info.state = 'stopped';
199
+ info.stoppedAt = Date.now();
200
+ info.lastUpdated = Date.now();
201
+ this.emit('update', info);
202
+ this.scrollbackBuffer.flushAll();
203
+ this.sessionStore.save(info);
204
+ }
205
+ }
206
+ resume(instanceId) {
207
+ const old = this.instances.get(instanceId);
208
+ if (!old || !old.managed)
209
+ return null;
210
+ const newId = generateInstanceId();
211
+ const now = Date.now();
212
+ // Only use --resume with a real session ID from hooks; otherwise use --continue
213
+ const resumeId = old.claudeSessionId || undefined;
214
+ const info = {
215
+ id: newId,
216
+ name: old.name,
217
+ managed: true,
218
+ cwd: old.cwd,
219
+ state: 'launching',
220
+ lastUpdated: now,
221
+ createdAt: now,
222
+ model: old.model,
223
+ permissionMode: old.permissionMode,
224
+ previousInstanceId: instanceId,
225
+ gitBranch: getGitBranch(old.cwd) || old.gitBranch,
226
+ };
227
+ this.instances.set(newId, info);
228
+ this.managedIds.add(newId);
229
+ if (this.autoNameIds.has(instanceId)) {
230
+ this.autoNameIds.add(newId);
231
+ }
232
+ const oldPromptCount = this.promptCount.get(instanceId);
233
+ if (oldPromptCount !== undefined) {
234
+ this.promptCount.set(newId, oldPromptCount);
235
+ }
236
+ this.subscribers.set(newId, new Set());
237
+ try {
238
+ this.ptyManager.spawn(newId, old.cwd, {
239
+ model: old.model,
240
+ permissionMode: old.permissionMode,
241
+ claudeSessionId: resumeId,
242
+ resume: true,
243
+ });
244
+ }
245
+ catch (err) {
246
+ info.state = 'stopped';
247
+ this.emit('update', info);
248
+ return info;
249
+ }
250
+ this.emit('update', info);
251
+ this.sessionStore.save(info);
252
+ this.scheduleLaunchTransition(newId);
253
+ // Remove the old stopped instance from the list and disk
254
+ this.instances.delete(instanceId);
255
+ this.managedIds.delete(instanceId);
256
+ this.autoNameIds.delete(instanceId);
257
+ this.promptCount.delete(instanceId);
258
+ this.sessionStore.remove(instanceId);
259
+ this.scrollbackBuffer.remove(instanceId);
260
+ this.emit('remove', instanceId);
261
+ return info;
262
+ }
263
+ dismiss(instanceId) {
264
+ this.instances.delete(instanceId);
265
+ this.managedIds.delete(instanceId);
266
+ this.subscribers.delete(instanceId);
267
+ this.sessionStore.remove(instanceId);
268
+ this.scrollbackBuffer.remove(instanceId);
269
+ this.emit('remove', instanceId);
270
+ }
271
+ getScrollback(instanceId) {
272
+ return this.scrollbackBuffer.getBuffer(instanceId);
273
+ }
274
+ remove(instanceId) {
275
+ this.instances.delete(instanceId);
276
+ this.managedIds.delete(instanceId);
277
+ this.subscribers.delete(instanceId);
278
+ this.emit('remove', instanceId);
279
+ }
280
+ handleHookUpdate(data) {
281
+ const existing = this.instances.get(data.id);
282
+ // Completion notification: Notification immediately after Stop = "task done", not permission prompt.
283
+ // Clear hookEvent so lastHookEvent stays 'Stop' — both POST and file-watcher deliver
284
+ // each event, so the duplicate must also be suppressed.
285
+ if (data.hookEvent === 'Notification' && existing?.lastHookEvent === 'Stop') {
286
+ this.log.info(`suppressing waiting state for ${data.id} — Notification after Stop (task completion, not permission prompt)`);
287
+ data.state = 'idle';
288
+ data.hookEvent = undefined;
289
+ }
290
+ if (data.state === 'stopped') {
291
+ this.log.info(`hook update with stopped state: id=${data.id} managed=${this.managedIds.has(data.id)} ptyAlive=${this.ptyManager.has(data.id)}`);
292
+ // Ignore stopped state from hooks if the PTY is still alive — this happens when
293
+ // Claude subtasks/subagents end and fire SessionEnd with the parent's instance ID
294
+ if (this.managedIds.has(data.id) && this.ptyManager.has(data.id)) {
295
+ this.log.info(`ignoring stopped hook for ${data.id} — PTY still alive (likely subtask exit)`);
296
+ return;
297
+ }
298
+ }
299
+ // Auto-name: use topic or subtask, refreshing every 5 prompts
300
+ let name = existing?.name || data.id;
301
+ if (this.autoNameIds.has(data.id)) {
302
+ const autoName = data.subtask || data.topic;
303
+ if (autoName) {
304
+ // Track prompt count — topic is set on UserPromptSubmit events
305
+ if (data.topic) {
306
+ const count = (this.promptCount.get(data.id) || 0) + 1;
307
+ this.promptCount.set(data.id, count);
308
+ // Rename on first prompt and every 5 prompts after
309
+ if (count === 1 || count % 5 === 0) {
310
+ name = autoName;
311
+ }
312
+ }
313
+ else {
314
+ // subtask update without a prompt — always use it
315
+ name = autoName;
316
+ }
317
+ }
318
+ }
319
+ // Capture claude session ID from hook data
320
+ const claudeSessionId = data.sessionId || existing?.claudeSessionId;
321
+ const info = {
322
+ id: data.id,
323
+ name,
324
+ managed: this.managedIds.has(data.id),
325
+ cwd: data.cwd,
326
+ gitBranch: data.gitBranch,
327
+ state: data.state,
328
+ ticket: data.ticket,
329
+ subtask: data.subtask,
330
+ progress: data.progress,
331
+ currentTool: data.currentTool,
332
+ lastUpdated: data.lastUpdated,
333
+ model: data.model || existing?.model,
334
+ createdAt: existing?.createdAt,
335
+ claudeSessionId,
336
+ };
337
+ info.lastHookUpdate = Date.now();
338
+ info.lastHookEvent = data.hookEvent || existing?.lastHookEvent;
339
+ this.applyTicketFields(info, data.ticket, data.ticketStatus);
340
+ this.instances.set(data.id, info);
341
+ this.emit('update', info);
342
+ // Save to session store on meaningful hook updates for managed instances
343
+ if (this.managedIds.has(data.id) && claudeSessionId) {
344
+ this.sessionStore.save(info);
345
+ }
346
+ }
347
+ getAll() {
348
+ return Array.from(this.instances.values());
349
+ }
350
+ get(id) {
351
+ return this.instances.get(id);
352
+ }
353
+ isManaged(id) {
354
+ return this.managedIds.has(id);
355
+ }
356
+ /** Save all running instances as stopped (for graceful shutdown). */
357
+ saveAllAsStopped() {
358
+ this.log.info(`saveAllAsStopped() called — shutting down`);
359
+ const now = Date.now();
360
+ for (const [id, info] of this.instances) {
361
+ if (this.managedIds.has(id) && info.state !== 'stopped') {
362
+ this.log.info(`marking stopped for shutdown: id=${id} name="${info.name}"`);
363
+ info.state = 'stopped';
364
+ info.stoppedAt = now;
365
+ info.lastUpdated = now;
366
+ this.sessionStore.save(info, { autoResume: true });
367
+ }
368
+ }
369
+ }
370
+ staleCheckCycle = 0;
371
+ startStaleCheck() {
372
+ this.staleTimer = setInterval(() => {
373
+ const now = Date.now();
374
+ this.staleCheckCycle++;
375
+ for (const [id, info] of this.instances) {
376
+ if (info.state === 'stopped')
377
+ continue;
378
+ // Tiered git branch refresh for managed instances
379
+ if (this.managedIds.has(id) && info.state !== 'launching') {
380
+ const shouldRefreshGit = info.state === 'running' || info.state === 'waiting'
381
+ ? true // every cycle (10s)
382
+ : this.staleCheckCycle % 6 === 0; // idle: every 6th cycle (60s)
383
+ if (shouldRefreshGit) {
384
+ const branch = getGitBranch(info.cwd);
385
+ if (branch && branch !== info.gitBranch) {
386
+ info.gitBranch = branch;
387
+ this.emit('update', info);
388
+ }
389
+ }
390
+ }
391
+ // Terminal-based state fallback for managed instances
392
+ if (this.managedIds.has(id)) {
393
+ const hookSilent = !info.lastHookUpdate || (now - info.lastHookUpdate > HOOK_SILENCE_THRESHOLD_MS);
394
+ // Check if PTY is dead but state isn't stopped
395
+ if (!this.ptyManager.has(id)) {
396
+ this.log.info(`stale check: PTY dead for ${id} (${info.name}), marking stopped`);
397
+ info.state = 'stopped';
398
+ info.stoppedAt = now;
399
+ info.lastUpdated = now;
400
+ this.emit('update', info);
401
+ this.sessionStore.save(info);
402
+ continue;
403
+ }
404
+ // If hooks have been silent, try terminal-based detection
405
+ if (hookSilent) {
406
+ const tail = this.scrollbackBuffer.getTail(id, 500);
407
+ const detected = detectStateFromTerminal(tail);
408
+ if (detected && detected !== info.state) {
409
+ this.log.info(`terminal fallback: ${id} (${info.name}) ${info.state} → ${detected}`);
410
+ info.state = detected;
411
+ info.lastUpdated = now;
412
+ this.emit('update', info);
413
+ }
414
+ }
415
+ }
416
+ }
417
+ }, 10_000);
418
+ }
419
+ /** Recompute ticket URLs for all instances (call after JIRA settings change). */
420
+ refreshTicketFields() {
421
+ for (const info of this.instances.values()) {
422
+ const oldUrl = info.ticketUrl;
423
+ info.ticketUrl = this.computeTicketUrl(info.ticket);
424
+ if (info.ticketUrl !== oldUrl) {
425
+ this.emit('update', info);
426
+ }
427
+ }
428
+ }
429
+ stop() {
430
+ if (this.staleTimer)
431
+ clearInterval(this.staleTimer);
432
+ this.discovery.stop();
433
+ }
434
+ }
@@ -0,0 +1,33 @@
1
+ import { createLogger } from './util/logger.js';
2
+ const log = createLogger('jira');
3
+ export async function fetchJiraStatus(baseUrl, email, apiToken, issueKey) {
4
+ const url = `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=status`;
5
+ const auth = Buffer.from(`${email}:${apiToken}`).toString('base64');
6
+ try {
7
+ const controller = new AbortController();
8
+ const timeout = setTimeout(() => controller.abort(), 5000);
9
+ const res = await fetch(url, {
10
+ headers: {
11
+ Authorization: `Basic ${auth}`,
12
+ Accept: 'application/json',
13
+ },
14
+ signal: controller.signal,
15
+ });
16
+ clearTimeout(timeout);
17
+ if (!res.ok) {
18
+ log.error(`JIRA API returned ${res.status} for ${issueKey}`);
19
+ return null;
20
+ }
21
+ const data = await res.json();
22
+ return data?.fields?.status?.name ?? null;
23
+ }
24
+ catch (err) {
25
+ if (err.name === 'AbortError') {
26
+ log.error(`JIRA API timeout for ${issueKey}`);
27
+ }
28
+ else {
29
+ log.error(`JIRA API error for ${issueKey}:`, err.message);
30
+ }
31
+ return null;
32
+ }
33
+ }
@@ -0,0 +1,98 @@
1
+ import * as pty from '@lydell/node-pty';
2
+ import os from 'os';
3
+ import { EventEmitter } from 'events';
4
+ import { getDefaultShell, getShellArgs } from './util/platform.js';
5
+ import { shellQuote, isValidModel, isValidPermissionMode, isValidSessionId } from './util/sanitize.js';
6
+ import { createLogger } from './util/logger.js';
7
+ function expandHome(p) {
8
+ let resolved = p;
9
+ if (resolved.startsWith('~/') || resolved === '~') {
10
+ resolved = os.homedir() + resolved.slice(1);
11
+ }
12
+ // Strip trailing slash (node-pty can choke on it)
13
+ if (resolved.length > 1 && resolved.endsWith('/')) {
14
+ resolved = resolved.slice(0, -1);
15
+ }
16
+ return resolved;
17
+ }
18
+ const log = createLogger('pty');
19
+ export class PtyManager extends EventEmitter {
20
+ ptys = new Map();
21
+ spawn(instanceId, cwd, opts) {
22
+ const shell = getDefaultShell();
23
+ const args = getShellArgs(shell);
24
+ const resolvedCwd = expandHome(cwd);
25
+ log.info(`Spawning: shell=${shell} cwd=${resolvedCwd} id=${instanceId}`);
26
+ let p;
27
+ try {
28
+ p = pty.spawn(shell, args, {
29
+ name: 'xterm-256color',
30
+ cols: 120,
31
+ rows: 30,
32
+ cwd: resolvedCwd,
33
+ env: {
34
+ ...process.env,
35
+ MOB_INSTANCE_ID: instanceId,
36
+ TERM: 'xterm-256color',
37
+ },
38
+ });
39
+ }
40
+ catch (err) {
41
+ log.error(`Failed to spawn PTY:`, err);
42
+ this.emit('error', instanceId, err);
43
+ throw err;
44
+ }
45
+ log.info(`PTY spawned: pid=${p.pid}`);
46
+ this.ptys.set(instanceId, p);
47
+ p.onData((data) => {
48
+ this.emit('data', instanceId, data);
49
+ });
50
+ p.onExit(({ exitCode }) => {
51
+ log.info(`PTY exited: id=${instanceId} code=${exitCode}`);
52
+ this.ptys.delete(instanceId);
53
+ this.emit('exit', instanceId, exitCode);
54
+ });
55
+ // Build claude command with validated & shell-quoted arguments
56
+ setTimeout(() => {
57
+ let cmd = 'claude';
58
+ if (opts?.claudeSessionId && isValidSessionId(opts.claudeSessionId)) {
59
+ cmd += ` --resume ${shellQuote(opts.claudeSessionId)}`;
60
+ }
61
+ else if (opts?.resume) {
62
+ cmd += ' --continue';
63
+ }
64
+ if (!opts?.claudeSessionId && !opts?.resume) {
65
+ cmd += ` --name ${shellQuote('mob-' + instanceId)}`;
66
+ }
67
+ if (opts?.model && isValidModel(opts.model)) {
68
+ cmd += ` --model ${shellQuote(opts.model)}`;
69
+ }
70
+ if (opts?.permissionMode && isValidPermissionMode(opts.permissionMode)) {
71
+ cmd += ` --permission-mode ${shellQuote(opts.permissionMode)}`;
72
+ }
73
+ log.info(`Sending command: ${cmd}`);
74
+ p.write(cmd + '\r');
75
+ }, 500);
76
+ return p;
77
+ }
78
+ write(instanceId, data) {
79
+ this.ptys.get(instanceId)?.write(data);
80
+ }
81
+ resize(instanceId, cols, rows) {
82
+ this.ptys.get(instanceId)?.resize(cols, rows);
83
+ }
84
+ kill(instanceId) {
85
+ const p = this.ptys.get(instanceId);
86
+ if (p) {
87
+ log.info(`Killing PTY: id=${instanceId} pid=${p.pid}`);
88
+ p.kill();
89
+ this.ptys.delete(instanceId);
90
+ }
91
+ }
92
+ has(instanceId) {
93
+ return this.ptys.has(instanceId);
94
+ }
95
+ getAll() {
96
+ return this.ptys;
97
+ }
98
+ }
@@ -0,0 +1,102 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getScrollbackDir } from './util/platform.js';
4
+ import { SCROLLBACK_MAX_BYTES, SCROLLBACK_FLUSH_MS } from '../shared/constants.js';
5
+ function log(...args) {
6
+ const ts = new Date().toISOString().slice(11, 23);
7
+ console.log(`[${ts}] [scrollback]`, ...args);
8
+ }
9
+ export class ScrollbackBuffer {
10
+ buffers = new Map();
11
+ flushTimer = null;
12
+ start() {
13
+ this.flushTimer = setInterval(() => this.flushDirty(), SCROLLBACK_FLUSH_MS);
14
+ }
15
+ append(instanceId, data) {
16
+ let entry = this.buffers.get(instanceId);
17
+ if (!entry) {
18
+ entry = { chunks: [], byteLength: 0, dirty: false };
19
+ this.buffers.set(instanceId, entry);
20
+ }
21
+ const bytes = Buffer.byteLength(data, 'utf8');
22
+ entry.chunks.push(data);
23
+ entry.byteLength += bytes;
24
+ entry.dirty = true;
25
+ // Trim oldest chunks if over max
26
+ while (entry.byteLength > SCROLLBACK_MAX_BYTES && entry.chunks.length > 1) {
27
+ const removed = entry.chunks.shift();
28
+ entry.byteLength -= Buffer.byteLength(removed, 'utf8');
29
+ }
30
+ }
31
+ getTail(instanceId, chars) {
32
+ const entry = this.buffers.get(instanceId);
33
+ if (!entry || entry.chunks.length === 0)
34
+ return '';
35
+ // Walk chunks from the end, collecting up to `chars` characters
36
+ let result = '';
37
+ for (let i = entry.chunks.length - 1; i >= 0 && result.length < chars; i--) {
38
+ result = entry.chunks[i] + result;
39
+ }
40
+ return result.length > chars ? result.slice(-chars) : result;
41
+ }
42
+ getBuffer(instanceId) {
43
+ const entry = this.buffers.get(instanceId);
44
+ if (entry)
45
+ return entry.chunks.join('');
46
+ // Try reading from disk
47
+ const filePath = path.join(getScrollbackDir(), `${instanceId}.log`);
48
+ try {
49
+ return fs.readFileSync(filePath, 'utf8');
50
+ }
51
+ catch {
52
+ return '';
53
+ }
54
+ }
55
+ flushDirty() {
56
+ const dir = getScrollbackDir();
57
+ for (const [instanceId, entry] of this.buffers) {
58
+ if (!entry.dirty)
59
+ continue;
60
+ try {
61
+ const filePath = path.join(dir, `${instanceId}.log`);
62
+ fs.writeFileSync(filePath, entry.chunks.join(''), 'utf8');
63
+ entry.dirty = false;
64
+ }
65
+ catch (err) {
66
+ log(`Failed to flush ${instanceId}:`, err);
67
+ }
68
+ }
69
+ }
70
+ flushAll() {
71
+ const dir = getScrollbackDir();
72
+ for (const [instanceId, entry] of this.buffers) {
73
+ if (!entry.dirty)
74
+ continue;
75
+ try {
76
+ const filePath = path.join(dir, `${instanceId}.log`);
77
+ fs.writeFileSync(filePath, entry.chunks.join(''), 'utf8');
78
+ entry.dirty = false;
79
+ }
80
+ catch (err) {
81
+ log(`Failed to flush ${instanceId}:`, err);
82
+ }
83
+ }
84
+ }
85
+ remove(instanceId) {
86
+ this.buffers.delete(instanceId);
87
+ const filePath = path.join(getScrollbackDir(), `${instanceId}.log`);
88
+ try {
89
+ fs.unlinkSync(filePath);
90
+ }
91
+ catch {
92
+ // File may not exist
93
+ }
94
+ }
95
+ stop() {
96
+ if (this.flushTimer) {
97
+ clearInterval(this.flushTimer);
98
+ this.flushTimer = null;
99
+ }
100
+ this.flushAll();
101
+ }
102
+ }