forked from doctly/switchboard
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsession-transitions.js
More file actions
200 lines (178 loc) · 8.8 KB
/
Copy pathsession-transitions.js
File metadata and controls
200 lines (178 loc) · 8.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
const path = require('path');
const fs = require('fs');
/**
* Fork / plan-accept detection for active PTY sessions.
* Call init(ctx) once with shared context.
*/
let PROJECTS_DIR, activeSessions, getMainWindow, log, rekeyMcpServer;
function init(ctx) {
PROJECTS_DIR = ctx.PROJECTS_DIR;
activeSessions = ctx.activeSessions;
getMainWindow = ctx.getMainWindow;
log = ctx.log;
rekeyMcpServer = ctx.rekeyMcpServer;
}
// --- Fork / plan-accept detection ---
/** Read first few lines of a new .jsonl to extract signals.
* Skips file-history-snapshot lines which can be very large (tens of KB)
* and reads up to 512KB to find the first user/assistant entry. */
function readNewSessionSignals(filePath) {
try {
const fd = fs.openSync(filePath, 'r');
const buf = Buffer.alloc(524288);
const bytesRead = fs.readSync(fd, buf, 0, 524288, 0);
fs.closeSync(fd);
const head = buf.toString('utf8', 0, bytesRead);
const lines = head.split('\n').filter(Boolean);
let forkedFrom = null;
let planContent = false;
let slug = null;
let parentSessionId = null;
let hasSnapshots = false;
for (const line of lines) {
const entry = JSON.parse(line);
// Skip snapshot lines — they carry no fork/session signals
if (entry.type === 'file-history-snapshot') { hasSnapshots = true; continue; }
if (entry.forkedFrom) forkedFrom = entry.forkedFrom.sessionId;
if (entry.planContent) planContent = true;
if (entry.slug && !slug) slug = entry.slug;
// --fork-session copies messages with original sessionId
if (entry.sessionId && !parentSessionId) parentSessionId = entry.sessionId;
// Stop after finding a user or assistant message
if (entry.type === 'user' || entry.type === 'assistant') break;
}
return { forkedFrom, planContent, slug, parentSessionId, hasSnapshots };
} catch {
return { forkedFrom: null, planContent: false, slug: null, parentSessionId: null, hasSnapshots: false };
}
}
/** Read tail of old session file for ExitPlanMode and slug */
function readOldSessionTail(filePath) {
try {
const stat = fs.statSync(filePath);
const size = stat.size;
const readSize = Math.min(size, 8192);
const buf = Buffer.alloc(readSize);
const fd = fs.openSync(filePath, 'r');
fs.readSync(fd, buf, 0, readSize, size - readSize);
fs.closeSync(fd);
const tail = buf.toString('utf8');
const hasExitPlanMode = tail.includes('ExitPlanMode');
// Extract slug from tail (last occurrence)
let slug = null;
const slugMatches = tail.match(/"slug"\s*:\s*"([^"]+)"/g);
if (slugMatches) {
const last = slugMatches[slugMatches.length - 1].match(/"slug"\s*:\s*"([^"]+)"/);
if (last) slug = last[1];
}
return { hasExitPlanMode, slug };
} catch {
return { hasExitPlanMode: false, slug: null };
}
}
/** Detect fork or plan-accept transitions for active PTY sessions in a folder */
function detectSessionTransitions(folder) {
const folderPath = path.join(PROJECTS_DIR, folder);
let currentFiles;
try {
currentFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl'));
} catch { return; }
for (const [sessionId, session] of [...activeSessions]) {
if (session.exited || session.isPlainTerminal || !session.knownJsonlFiles || session.projectFolder !== folder) {
if (!session.exited && !session.isPlainTerminal && session.forkFrom) {
log.info(`[fork-detect] skipped session=${sessionId} forkFrom=${session.forkFrom||'none'} reason=${session.exited ? 'exited' : session.isPlainTerminal ? 'terminal' : !session.knownJsonlFiles ? 'noKnown' : 'folderMismatch('+session.projectFolder+' vs '+folder+')'}`);
}
continue;
}
const newFiles = currentFiles.filter(f => !session.knownJsonlFiles.has(f));
if (newFiles.length > 0) log.debug(`[detect] session=${sessionId} forkFrom=${session.forkFrom||'none'} folder=${folder} newFiles=${newFiles.length} knownCount=${session.knownJsonlFiles.size} currentCount=${currentFiles.length}`);
if (newFiles.length === 0) continue;
const emptyFiles = new Set(); // files with no signals yet (still being written)
for (const newFile of newFiles) {
const newFilePath = path.join(folderPath, newFile);
const newId = path.basename(newFile, '.jsonl');
const signals = readNewSessionSignals(newFilePath);
// File exists but has no parseable content yet — skip and retry next cycle
// But if the file's mtime is older than 1 hour, treat it as stale and archive it
if (!signals.forkedFrom && !signals.parentSessionId && !signals.slug && !signals.planContent) {
// Fork file with only snapshots (no user turn yet) — match immediately
if (signals.hasSnapshots && session.forkFrom && !session.realSessionId) {
log.info(`[detect] session=${sessionId} matching snapshot-only fork file=${newId}`);
// Fall through to matching logic — will match via the fork-snapshot path below
} else {
let stale = false;
try {
const mtime = fs.statSync(path.join(folderPath, newFile)).mtimeMs;
if (Date.now() - mtime > 3600000) stale = true;
} catch {}
if (stale) {
log.info(`[detect] session=${sessionId} archiving stale empty file=${newId}`);
} else {
emptyFiles.add(newFile);
}
continue;
}
}
if (session.forkFrom) {
log.info(`[detect] session=${sessionId} checking newFile=${newId} signals=${JSON.stringify({forkedFrom: signals.forkedFrom||null, parentSessionId: signals.parentSessionId||null, slug: signals.slug||null})} forkFrom=${session.forkFrom}`);
} else {
log.debug(`[detect] session=${sessionId} checking newFile=${newId} signals=${JSON.stringify({forkedFrom: signals.forkedFrom||null, parentSessionId: signals.parentSessionId||null, slug: signals.slug||null})} forkFrom=none`);
}
let matched = false;
// Fork: forkedFrom.sessionId matches this active PTY or the session it was forked from
if (signals.forkedFrom === sessionId || (session.forkFrom && signals.forkedFrom === session.forkFrom)) {
matched = true;
}
// --fork-session: new file's parentSessionId matches the forkFrom source,
// and the new file's name (newId) differs from both our PTY id and the source
if (!matched && session.forkFrom && signals.parentSessionId === session.forkFrom && newId !== session.forkFrom) {
matched = true;
}
// Fork file with only snapshots — no user turn yet, but this session is waiting for a fork
if (!matched && signals.hasSnapshots && session.forkFrom && !session.realSessionId) {
matched = true;
}
if (session.forkFrom && !matched) {
log.info(`[detect] session=${sessionId} NO MATCH for newFile=${newId} forkFrom=${session.forkFrom} parentSessionId=${signals.parentSessionId||'null'} forkedFrom=${signals.forkedFrom||'null'}`);
}
// Plan-accept: shared slug + planContent + old session has ExitPlanMode
if (!matched && signals.planContent && signals.slug) {
const oldFilePath = path.join(folderPath, sessionId + '.jsonl');
const oldTail = readOldSessionTail(oldFilePath);
if (oldTail.hasExitPlanMode && oldTail.slug === signals.slug) {
// Temporal check: new file created within 30s of old file's last modification
try {
const oldMtime = fs.statSync(oldFilePath).mtimeMs;
const newMtime = fs.statSync(newFilePath).mtimeMs;
if (Math.abs(newMtime - oldMtime) < 30000) {
matched = true;
}
} catch {}
}
}
if (matched) {
log.info(`[session-transition] ${sessionId} → ${newId} (${signals.forkedFrom || session.forkFrom ? 'fork' : 'plan-accept'})`);
session.knownJsonlFiles = new Set(currentFiles);
session.realSessionId = newId;
// Update slug from new session
if (signals.slug) session.sessionSlug = signals.slug;
activeSessions.delete(sessionId);
activeSessions.set(newId, session);
// Re-key MCP server to match new session ID
rekeyMcpServer(sessionId, newId);
// Re-key any profile-icon mapping recorded against the temp id.
try { require('./session-profiles').rekeySession(sessionId, newId); } catch {}
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('session-forked', sessionId, newId);
}
break; // Only one transition per session per flush
}
}
// Update known files, but exclude empty ones so they get rechecked next cycle
const updated = new Set(currentFiles);
for (const f of emptyFiles) updated.delete(f);
session.knownJsonlFiles = updated;
}
}
module.exports = { init, detectSessionTransitions };