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.
- package/README.md +207 -0
- package/bin/mob.js +20 -0
- package/dist/client/assets/index-BE5nxcXU.css +32 -0
- package/dist/client/assets/index-DkdPImCW.js +11 -0
- package/dist/client/index.html +14 -0
- package/dist/server/server/__tests__/sanitize.test.js +154 -0
- package/dist/server/server/discovery.js +36 -0
- package/dist/server/server/express-app.js +173 -0
- package/dist/server/server/index.js +54 -0
- package/dist/server/server/instance-manager.js +434 -0
- package/dist/server/server/jira-client.js +33 -0
- package/dist/server/server/pty-manager.js +98 -0
- package/dist/server/server/scrollback-buffer.js +102 -0
- package/dist/server/server/session-store.js +127 -0
- package/dist/server/server/settings-manager.js +87 -0
- package/dist/server/server/status-reader.js +29 -0
- package/dist/server/server/terminal-state-detector.js +42 -0
- package/dist/server/server/types.js +1 -0
- package/dist/server/server/util/id.js +4 -0
- package/dist/server/server/util/logger.js +34 -0
- package/dist/server/server/util/platform.js +48 -0
- package/dist/server/server/util/sanitize.js +126 -0
- package/dist/server/server/ws-server.js +197 -0
- package/dist/server/shared/constants.js +8 -0
- package/dist/server/shared/protocol.js +1 -0
- package/dist/server/shared/settings.js +54 -0
- package/package.json +68 -0
- package/scripts/postinstall.cjs +68 -0
|
@@ -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
|
+
}
|