hammoc 1.0.4 → 1.1.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 +46 -16
- package/bin/hammoc.js +22 -5
- package/package.json +2 -1
- package/packages/client/dist/assets/index-JvaBmdnx.css +32 -0
- package/packages/client/dist/assets/index-RJTIkL2R.js +1446 -0
- package/packages/client/dist/assets/{index-Zkw0a1l9.js → index-cwHQKX3r.js} +1 -1
- package/packages/client/dist/index.html +2 -2
- package/packages/client/dist/sw.js +2 -1
- package/packages/server/dist/app.d.ts.map +1 -1
- package/packages/server/dist/app.js +58 -6
- package/packages/server/dist/app.js.map +1 -1
- package/packages/server/dist/config/index.d.ts +17 -9
- package/packages/server/dist/config/index.d.ts.map +1 -1
- package/packages/server/dist/config/index.js +17 -10
- package/packages/server/dist/config/index.js.map +1 -1
- package/packages/server/dist/controllers/boardController.d.ts +0 -1
- package/packages/server/dist/controllers/boardController.d.ts.map +1 -1
- package/packages/server/dist/controllers/boardController.js +62 -29
- package/packages/server/dist/controllers/boardController.js.map +1 -1
- package/packages/server/dist/controllers/cliController.d.ts.map +1 -1
- package/packages/server/dist/controllers/cliController.js +8 -0
- package/packages/server/dist/controllers/cliController.js.map +1 -1
- package/packages/server/dist/controllers/fileSystemController.d.ts +10 -0
- package/packages/server/dist/controllers/fileSystemController.d.ts.map +1 -1
- package/packages/server/dist/controllers/fileSystemController.js +156 -0
- package/packages/server/dist/controllers/fileSystemController.js.map +1 -1
- package/packages/server/dist/controllers/queueController.d.ts +0 -4
- package/packages/server/dist/controllers/queueController.d.ts.map +1 -1
- package/packages/server/dist/controllers/queueController.js +0 -88
- package/packages/server/dist/controllers/queueController.js.map +1 -1
- package/packages/server/dist/controllers/queueTemplateController.d.ts +4 -0
- package/packages/server/dist/controllers/queueTemplateController.d.ts.map +1 -1
- package/packages/server/dist/controllers/queueTemplateController.js +61 -4
- package/packages/server/dist/controllers/queueTemplateController.js.map +1 -1
- package/packages/server/dist/controllers/serverController.d.ts +3 -3
- package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
- package/packages/server/dist/controllers/serverController.js +42 -16
- package/packages/server/dist/controllers/serverController.js.map +1 -1
- package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
- package/packages/server/dist/controllers/sessionController.js +3 -1
- package/packages/server/dist/controllers/sessionController.js.map +1 -1
- package/packages/server/dist/handlers/websocket.d.ts +14 -2
- package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
- package/packages/server/dist/handlers/websocket.js +780 -99
- package/packages/server/dist/handlers/websocket.js.map +1 -1
- package/packages/server/dist/index.js +5 -1
- package/packages/server/dist/index.js.map +1 -1
- package/packages/server/dist/locales/en/server.json +12 -3
- package/packages/server/dist/locales/es/server.json +3 -1
- package/packages/server/dist/locales/ja/server.json +3 -1
- package/packages/server/dist/locales/ko/server.json +12 -3
- package/packages/server/dist/locales/pt/server.json +3 -1
- package/packages/server/dist/locales/zh-CN/server.json +3 -1
- package/packages/server/dist/middleware/pathGuard.d.ts +1 -7
- package/packages/server/dist/middleware/pathGuard.d.ts.map +1 -1
- package/packages/server/dist/middleware/pathGuard.js +57 -4
- package/packages/server/dist/middleware/pathGuard.js.map +1 -1
- package/packages/server/dist/middleware/session.d.ts.map +1 -1
- package/packages/server/dist/middleware/session.js +3 -1
- package/packages/server/dist/middleware/session.js.map +1 -1
- package/packages/server/dist/routes/board.d.ts.map +1 -1
- package/packages/server/dist/routes/board.js +0 -1
- package/packages/server/dist/routes/board.js.map +1 -1
- package/packages/server/dist/routes/fileSystem.d.ts.map +1 -1
- package/packages/server/dist/routes/fileSystem.js +39 -0
- package/packages/server/dist/routes/fileSystem.js.map +1 -1
- package/packages/server/dist/routes/preferences.d.ts.map +1 -1
- package/packages/server/dist/routes/preferences.js +80 -2
- package/packages/server/dist/routes/preferences.js.map +1 -1
- package/packages/server/dist/routes/queue.d.ts.map +1 -1
- package/packages/server/dist/routes/queue.js +9 -8
- package/packages/server/dist/routes/queue.js.map +1 -1
- package/packages/server/dist/services/bmadStatusService.d.ts.map +1 -1
- package/packages/server/dist/services/bmadStatusService.js +60 -0
- package/packages/server/dist/services/bmadStatusService.js.map +1 -1
- package/packages/server/dist/services/chatService.d.ts +4 -0
- package/packages/server/dist/services/chatService.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.js +7 -1
- package/packages/server/dist/services/chatService.js.map +1 -1
- package/packages/server/dist/services/cliService.d.ts.map +1 -1
- package/packages/server/dist/services/cliService.js +66 -15
- package/packages/server/dist/services/cliService.js.map +1 -1
- package/packages/server/dist/services/fileSystemService.d.ts +29 -1
- package/packages/server/dist/services/fileSystemService.d.ts.map +1 -1
- package/packages/server/dist/services/fileSystemService.js +240 -5
- package/packages/server/dist/services/fileSystemService.js.map +1 -1
- package/packages/server/dist/services/gitService.js +1 -1
- package/packages/server/dist/services/gitService.js.map +1 -1
- package/packages/server/dist/services/historyParser.d.ts +13 -0
- package/packages/server/dist/services/historyParser.d.ts.map +1 -1
- package/packages/server/dist/services/historyParser.js +72 -1
- package/packages/server/dist/services/historyParser.js.map +1 -1
- package/packages/server/dist/services/issueService.d.ts +3 -10
- package/packages/server/dist/services/issueService.d.ts.map +1 -1
- package/packages/server/dist/services/issueService.js +123 -152
- package/packages/server/dist/services/issueService.js.map +1 -1
- package/packages/server/dist/services/notificationService.d.ts +11 -6
- package/packages/server/dist/services/notificationService.d.ts.map +1 -1
- package/packages/server/dist/services/notificationService.js +75 -20
- package/packages/server/dist/services/notificationService.js.map +1 -1
- package/packages/server/dist/services/preferencesService.d.ts +2 -3
- package/packages/server/dist/services/preferencesService.d.ts.map +1 -1
- package/packages/server/dist/services/preferencesService.js +3 -10
- package/packages/server/dist/services/preferencesService.js.map +1 -1
- package/packages/server/dist/services/projectService.d.ts +26 -1
- package/packages/server/dist/services/projectService.d.ts.map +1 -1
- package/packages/server/dist/services/projectService.js +113 -0
- package/packages/server/dist/services/projectService.js.map +1 -1
- package/packages/server/dist/services/queueService.d.ts +1 -1
- package/packages/server/dist/services/queueService.d.ts.map +1 -1
- package/packages/server/dist/services/queueService.js +6 -2
- package/packages/server/dist/services/queueService.js.map +1 -1
- package/packages/server/dist/services/queueTemplateService.d.ts +5 -0
- package/packages/server/dist/services/queueTemplateService.d.ts.map +1 -1
- package/packages/server/dist/services/queueTemplateService.js +71 -21
- package/packages/server/dist/services/queueTemplateService.js.map +1 -1
- package/packages/server/dist/services/sessionService.d.ts +12 -0
- package/packages/server/dist/services/sessionService.d.ts.map +1 -1
- package/packages/server/dist/services/sessionService.js +142 -107
- package/packages/server/dist/services/sessionService.js.map +1 -1
- package/packages/server/dist/services/streamHandler.d.ts +4 -2
- package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
- package/packages/server/dist/services/streamHandler.js +19 -4
- package/packages/server/dist/services/streamHandler.js.map +1 -1
- package/packages/server/dist/services/webPushService.d.ts +61 -0
- package/packages/server/dist/services/webPushService.d.ts.map +1 -0
- package/packages/server/dist/services/webPushService.js +258 -0
- package/packages/server/dist/services/webPushService.js.map +1 -0
- package/packages/server/dist/utils/networkUtils.d.ts +17 -2
- package/packages/server/dist/utils/networkUtils.d.ts.map +1 -1
- package/packages/server/dist/utils/networkUtils.js +121 -8
- package/packages/server/dist/utils/networkUtils.js.map +1 -1
- package/packages/server/package.json +4 -0
- package/packages/shared/dist/constants/errorCodes.d.ts +1 -0
- package/packages/shared/dist/constants/errorCodes.d.ts.map +1 -1
- package/packages/shared/dist/constants/errorCodes.js +2 -0
- package/packages/shared/dist/constants/errorCodes.js.map +1 -1
- package/packages/shared/dist/index.d.ts +1 -1
- package/packages/shared/dist/index.d.ts.map +1 -1
- package/packages/shared/dist/index.js.map +1 -1
- package/packages/shared/dist/types/bmadStatus.d.ts +2 -0
- package/packages/shared/dist/types/bmadStatus.d.ts.map +1 -1
- package/packages/shared/dist/types/bmadStatus.js.map +1 -1
- package/packages/shared/dist/types/board.d.ts +6 -8
- package/packages/shared/dist/types/board.d.ts.map +1 -1
- package/packages/shared/dist/types/board.js +24 -39
- package/packages/shared/dist/types/board.js.map +1 -1
- package/packages/shared/dist/types/fileSystem.d.ts +36 -0
- package/packages/shared/dist/types/fileSystem.d.ts.map +1 -1
- package/packages/shared/dist/types/fileSystem.js +15 -0
- package/packages/shared/dist/types/fileSystem.js.map +1 -1
- package/packages/shared/dist/types/history.d.ts +7 -0
- package/packages/shared/dist/types/history.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.d.ts +24 -2
- package/packages/shared/dist/types/preferences.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.js.map +1 -1
- package/packages/shared/dist/types/sdk.d.ts +7 -0
- package/packages/shared/dist/types/sdk.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.js +25 -0
- package/packages/shared/dist/types/sdk.js.map +1 -1
- package/packages/shared/dist/types/websocket.d.ts +41 -3
- package/packages/shared/dist/types/websocket.d.ts.map +1 -1
- package/packages/shared/dist/utils/queueTemplateUtils.d.ts +2 -1
- package/packages/shared/dist/utils/queueTemplateUtils.d.ts.map +1 -1
- package/packages/shared/dist/utils/queueTemplateUtils.js +10 -3
- package/packages/shared/dist/utils/queueTemplateUtils.js.map +1 -1
- package/packages/client/dist/assets/index-DgULSwlr.js +0 -1320
- package/packages/client/dist/assets/index-Dto2jQe7.css +0 -32
- package/packages/client/dist/workbox-7a79b53c.js +0 -1
|
@@ -50,26 +50,12 @@ function triggerDashboardStatusChange(projectSlug) {
|
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* Check terminal access for a socket connection.
|
|
53
|
-
*
|
|
53
|
+
* Checks server config (TERMINAL_ENABLED env var) and local IP.
|
|
54
54
|
* Story 17.5: Terminal Security
|
|
55
55
|
*/
|
|
56
|
-
|
|
56
|
+
function checkTerminalAccess(socket, lang) {
|
|
57
57
|
const t = i18next.getFixedT(lang);
|
|
58
|
-
|
|
59
|
-
const terminalEnabled = await preferencesService.getTerminalEnabled();
|
|
60
|
-
if (!terminalEnabled) {
|
|
61
|
-
return {
|
|
62
|
-
allowed: false,
|
|
63
|
-
error: {
|
|
64
|
-
code: TERMINAL_ERRORS.TERMINAL_DISABLED.code,
|
|
65
|
-
message: t('ws.error.terminalDisabled'),
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
catch (err) {
|
|
71
|
-
// Fail-closed: deny access if preferences read fails
|
|
72
|
-
log.error('Failed to check terminal enabled state, denying access:', err);
|
|
58
|
+
if (!preferencesService.getTerminalEnabled()) {
|
|
73
59
|
return {
|
|
74
60
|
allowed: false,
|
|
75
61
|
error: {
|
|
@@ -95,6 +81,267 @@ let connectedClients = 0;
|
|
|
95
81
|
// Primary maps: sessionId → ActiveStream, socketId → sessionId
|
|
96
82
|
const activeStreams = new Map();
|
|
97
83
|
const socketToSession = new Map();
|
|
84
|
+
// Story 24.3: Track which session room each socket joined (for session:leave room management)
|
|
85
|
+
const socketSessionRoom = new Map();
|
|
86
|
+
// Track which project room each socket joined (for leave on session switch)
|
|
87
|
+
const socketProjectRoom = new Map();
|
|
88
|
+
const chainState = new Map();
|
|
89
|
+
// Per-session drain generation counter for race guard
|
|
90
|
+
const chainDrainGeneration = new Map();
|
|
91
|
+
// Sessions that have completed at least one handleChatSend — safe to resume
|
|
92
|
+
const chainResumableSessions = new Set();
|
|
93
|
+
let chainItemCounter = 0;
|
|
94
|
+
const CHAIN_MAX_RETRIES = 3;
|
|
95
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
96
|
+
/** Generate a unique chain item ID */
|
|
97
|
+
function generateChainItemId() {
|
|
98
|
+
return `chain-${Date.now()}-${++chainItemCounter}`;
|
|
99
|
+
}
|
|
100
|
+
/** Map internal chain item to public PromptChainItem (allow-list of fields) */
|
|
101
|
+
function toPublicChainItem(item) {
|
|
102
|
+
const pub = { id: item.id, content: item.content, status: item.status, createdAt: item.createdAt };
|
|
103
|
+
if (item.retryCount !== undefined)
|
|
104
|
+
pub.retryCount = item.retryCount;
|
|
105
|
+
return pub;
|
|
106
|
+
}
|
|
107
|
+
/** Broadcast current chain state to all sockets in the session room (strips internal fields) */
|
|
108
|
+
function broadcastChainUpdate(sessionId) {
|
|
109
|
+
if (!io)
|
|
110
|
+
return;
|
|
111
|
+
const internalItems = chainState.get(sessionId) || [];
|
|
112
|
+
const items = internalItems.map(toPublicChainItem);
|
|
113
|
+
io.to(`session:${sessionId}`).emit('chain:update', { sessionId, items });
|
|
114
|
+
}
|
|
115
|
+
/** Broadcast chain state including persisted failures from disk */
|
|
116
|
+
function broadcastChainUpdateWithFailures(sessionId) {
|
|
117
|
+
if (!io)
|
|
118
|
+
return;
|
|
119
|
+
withChainFailureLock(sessionId, () => projectService.readChainFailures(sessionId))
|
|
120
|
+
.then(failures => {
|
|
121
|
+
// Re-read in-memory state now (may have changed during async disk read)
|
|
122
|
+
const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
|
|
123
|
+
io.to(`session:${sessionId}`).emit('chain:update', { sessionId, items: [...freshItems, ...failures] });
|
|
124
|
+
})
|
|
125
|
+
.catch((err) => {
|
|
126
|
+
log.error(`Failed to read chain failures for broadcast (session ${sessionId}):`, err);
|
|
127
|
+
const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
|
|
128
|
+
io.to(`session:${sessionId}`).emit('chain:update', { sessionId, items: freshItems });
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** Clean up chain state when no active work remains */
|
|
132
|
+
function cleanupChainIfIdle(sessionId) {
|
|
133
|
+
if (activeStreams.has(sessionId))
|
|
134
|
+
return;
|
|
135
|
+
const items = chainState.get(sessionId);
|
|
136
|
+
// Preserve if pending/sending items remain (drain will handle them)
|
|
137
|
+
// or failed items remain (disk persistence may have failed — keep in memory until dismissed)
|
|
138
|
+
if (items && items.some(item => item.status === 'pending' || item.status === 'sending' || item.status === 'failed')) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
chainState.delete(sessionId);
|
|
142
|
+
chainResumableSessions.delete(sessionId);
|
|
143
|
+
// NOTE: chainDrainGeneration is intentionally NOT deleted here.
|
|
144
|
+
// Deleting would reset the counter to 0, allowing stale timers from before
|
|
145
|
+
// cleanup to match a new gen=1 value (ABA problem).
|
|
146
|
+
}
|
|
147
|
+
// Per-session mutex for failure file I/O to prevent read-modify-write races
|
|
148
|
+
const chainFailureLocks = new Map();
|
|
149
|
+
/** Execute a failure file operation under per-session lock */
|
|
150
|
+
function withChainFailureLock(sessionId, fn) {
|
|
151
|
+
const prev = chainFailureLocks.get(sessionId) || Promise.resolve();
|
|
152
|
+
const next = prev.then(fn, fn); // run even if previous rejected
|
|
153
|
+
chainFailureLocks.set(sessionId, next.then(() => { }, () => { }));
|
|
154
|
+
return next;
|
|
155
|
+
}
|
|
156
|
+
/** Persist a failed chain item to disk so it survives server restarts */
|
|
157
|
+
async function persistChainFailure(sessionId, item) {
|
|
158
|
+
return withChainFailureLock(sessionId, async () => {
|
|
159
|
+
const existing = await projectService.readChainFailures(sessionId);
|
|
160
|
+
existing.push(toPublicChainItem(item));
|
|
161
|
+
await projectService.writeChainFailures(sessionId, existing);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** Remove a specific failure from disk */
|
|
165
|
+
async function removePersistedFailure(sessionId, itemId) {
|
|
166
|
+
return withChainFailureLock(sessionId, async () => {
|
|
167
|
+
const failures = await projectService.readChainFailures(sessionId);
|
|
168
|
+
if (failures.length === 0)
|
|
169
|
+
return;
|
|
170
|
+
const remaining = failures.filter(f => f.id !== itemId);
|
|
171
|
+
if (remaining.length !== failures.length) {
|
|
172
|
+
await projectService.writeChainFailures(sessionId, remaining);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/** Clear all persisted failures for a session */
|
|
177
|
+
async function clearPersistedFailures(sessionId) {
|
|
178
|
+
return withChainFailureLock(sessionId, async () => {
|
|
179
|
+
await projectService.writeChainFailures(sessionId, []);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** Schedule chain drain after stream completion (1s delay) */
|
|
183
|
+
function scheduleChainDrain(sessionId, lang) {
|
|
184
|
+
// Increment generation counter to detect stale drains
|
|
185
|
+
const gen = (chainDrainGeneration.get(sessionId) || 0) + 1;
|
|
186
|
+
chainDrainGeneration.set(sessionId, gen);
|
|
187
|
+
log.info(`[CHAIN-DRAIN] scheduleChainDrain called: sessionId=${sessionId}, gen=${gen}, chainItems=${chainState.get(sessionId)?.length ?? 0}, activeStream=${activeStreams.has(sessionId)}`);
|
|
188
|
+
setTimeout(async () => {
|
|
189
|
+
// Race guard: if generation changed (manual chat:send started/finished), abort
|
|
190
|
+
if (chainDrainGeneration.get(sessionId) !== gen) {
|
|
191
|
+
log.info(`[CHAIN-DRAIN] timer aborted (generation mismatch): sessionId=${sessionId}, expected=${gen}, actual=${chainDrainGeneration.get(sessionId)}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Race guard: if another stream is currently active, abort drain
|
|
195
|
+
if (activeStreams.has(sessionId)) {
|
|
196
|
+
log.info(`[CHAIN-DRAIN] timer aborted (active stream exists): sessionId=${sessionId}, gen=${gen}`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const items = chainState.get(sessionId);
|
|
200
|
+
log.info(`[CHAIN-DRAIN] timer fired: sessionId=${sessionId}, gen=${gen}, items=${items?.length ?? 0}, statuses=${JSON.stringify(items?.map(i => ({ id: i.id.slice(0, 8), status: i.status })) ?? [])}`);
|
|
201
|
+
if (!items || items.length === 0) {
|
|
202
|
+
cleanupChainIfIdle(sessionId);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const nextItem = items.find(item => item.status === 'pending');
|
|
206
|
+
if (!nextItem) {
|
|
207
|
+
log.info(`[CHAIN-DRAIN] no pending item found, cleaning up: sessionId=${sessionId}`);
|
|
208
|
+
cleanupChainIfIdle(sessionId);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// Use per-item execution context
|
|
212
|
+
const { workingDirectory, permissionMode, model } = nextItem;
|
|
213
|
+
// Mark item as 'sending' and broadcast (must happen before any await to prevent duplicate execution)
|
|
214
|
+
nextItem.status = 'sending';
|
|
215
|
+
log.info(`[CHAIN-DRAIN] executing item: sessionId=${sessionId}, itemId=${nextItem.id.slice(0, 8)}, content="${nextItem.content.slice(0, 80)}"`);
|
|
216
|
+
broadcastChainUpdate(sessionId);
|
|
217
|
+
const abortController = new AbortController();
|
|
218
|
+
let stream;
|
|
219
|
+
try {
|
|
220
|
+
// Create headless stream inside try — if this throws, sending status is recovered below
|
|
221
|
+
const headless = createHeadlessStream(sessionId, abortController);
|
|
222
|
+
stream = headless.stream;
|
|
223
|
+
log.info(`[CHAIN-DRAIN] headless stream created: sessionId=${sessionId}, socketsInRoom=${stream.sockets.size}`);
|
|
224
|
+
// Resolve projectSlug async (fire-and-forget) — same pattern as chat:send
|
|
225
|
+
projectService.findProjectByPath(workingDirectory).then((project) => {
|
|
226
|
+
if (project && stream.status === 'running' && activeStreams.get(stream.sessionId) === stream) {
|
|
227
|
+
sessionProjectMap.set(stream.sessionId, project.projectSlug);
|
|
228
|
+
triggerDashboardStatusChange(project.projectSlug);
|
|
229
|
+
}
|
|
230
|
+
}).catch((err) => {
|
|
231
|
+
log.warn(`[CHAIN-DRAIN] failed to resolve projectSlug for dashboard: sessionId=${sessionId}, dir=${workingDirectory}`, err);
|
|
232
|
+
});
|
|
233
|
+
emitStreamChange(sessionId, true, sessionProjectMap.get(sessionId) ?? null);
|
|
234
|
+
const drainSuccess = await handleChatSend(stream, { content: nextItem.content, workingDirectory, sessionId, resume: chainResumableSessions.has(sessionId) || undefined, permissionMode, model }, abortController, lang);
|
|
235
|
+
if (!drainSuccess)
|
|
236
|
+
throw new Error('handleChatSend returned false');
|
|
237
|
+
// Success: mark as 'sent' and remove from chain
|
|
238
|
+
nextItem.status = 'sent';
|
|
239
|
+
chainResumableSessions.add(sessionId);
|
|
240
|
+
log.info(`[CHAIN-DRAIN] item completed successfully: sessionId=${sessionId}, itemId=${nextItem.id.slice(0, 8)}`);
|
|
241
|
+
const currentItems = chainState.get(sessionId);
|
|
242
|
+
if (currentItems) {
|
|
243
|
+
chainState.set(sessionId, currentItems.filter(item => item.id !== nextItem.id));
|
|
244
|
+
}
|
|
245
|
+
broadcastChainUpdate(sessionId);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
// Check if this was an intentional abort (chain:remove or chain:clear)
|
|
249
|
+
const isAborted = abortController.signal.aborted;
|
|
250
|
+
const abortReason = abortController.signal.reason;
|
|
251
|
+
const isChainCanceled = isAborted && (abortReason === 'chain-item-removed' || abortReason === 'chain-cleared' || abortReason === 'user-abort');
|
|
252
|
+
if (isChainCanceled) {
|
|
253
|
+
// User-initiated cancel — remove from chain, no disk record needed.
|
|
254
|
+
// Mark as 'sent' so the finally safety-net doesn't reset it to 'pending'.
|
|
255
|
+
nextItem.status = 'sent';
|
|
256
|
+
const cancelItems = chainState.get(sessionId);
|
|
257
|
+
if (cancelItems) {
|
|
258
|
+
chainState.set(sessionId, cancelItems.filter(item => item.id !== nextItem.id));
|
|
259
|
+
}
|
|
260
|
+
log.info(`Chain item ${nextItem.id} canceled (${abortReason}) for session ${sessionId}`);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// On error: increment retry count and persist failure if max retries exceeded
|
|
264
|
+
const retries = (nextItem.retryCount || 0) + 1;
|
|
265
|
+
nextItem.retryCount = retries;
|
|
266
|
+
if (retries >= CHAIN_MAX_RETRIES) {
|
|
267
|
+
nextItem.status = 'failed';
|
|
268
|
+
// Persist to disk, then remove from memory only on success
|
|
269
|
+
try {
|
|
270
|
+
await persistChainFailure(sessionId, nextItem);
|
|
271
|
+
const failItems = chainState.get(sessionId);
|
|
272
|
+
if (failItems) {
|
|
273
|
+
chainState.set(sessionId, failItems.filter(item => item.id !== nextItem.id));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (persistErr) {
|
|
277
|
+
// Disk write failed — keep in memory so it's not lost
|
|
278
|
+
log.error(`Failed to persist chain failure for session ${sessionId}:`, persistErr);
|
|
279
|
+
}
|
|
280
|
+
log.error(`Chain item ${nextItem.id} failed after ${CHAIN_MAX_RETRIES} retries for session ${sessionId}:`, err);
|
|
281
|
+
}
|
|
282
|
+
else if (nextItem.status === 'sending') {
|
|
283
|
+
nextItem.status = 'pending';
|
|
284
|
+
}
|
|
285
|
+
log.error(`Chain drain error for session ${sessionId} (attempt ${retries}):`, err);
|
|
286
|
+
}
|
|
287
|
+
// Use disk-aware broadcast since failures may have been persisted above
|
|
288
|
+
broadcastChainUpdateWithFailures(sessionId);
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
// Safety net: ensure item is never left stuck in 'sending'
|
|
292
|
+
if (nextItem.status === 'sending') {
|
|
293
|
+
log.warn(`[CHAIN-DRAIN] finally: item still in 'sending', resetting to 'pending': sessionId=${sessionId}, itemId=${nextItem.id.slice(0, 8)}`);
|
|
294
|
+
nextItem.status = 'pending';
|
|
295
|
+
broadcastChainUpdate(sessionId);
|
|
296
|
+
}
|
|
297
|
+
if (stream) {
|
|
298
|
+
stream.status = 'completed';
|
|
299
|
+
const isCurrentStream = activeStreams.get(sessionId) === stream;
|
|
300
|
+
log.info(`[CHAIN-DRAIN] finally: sessionId=${sessionId}, streamCompleted=true, isCurrentStream=${isCurrentStream}`);
|
|
301
|
+
if (isCurrentStream) {
|
|
302
|
+
const remaining = chainState.get(sessionId);
|
|
303
|
+
const remainingPending = remaining?.filter(item => item.status === 'pending').length ?? 0;
|
|
304
|
+
log.info(`[CHAIN-DRAIN] finally: cleaning up stream, remainingItems=${remaining?.length ?? 0}, remainingPending=${remainingPending}`);
|
|
305
|
+
const chainEndSlug = sessionProjectMap.get(sessionId) ?? null;
|
|
306
|
+
cleanupStream(sessionId);
|
|
307
|
+
emitStreamChange(sessionId, false, chainEndSlug);
|
|
308
|
+
// Persist per-session permission mode before cleanup
|
|
309
|
+
const chainFinalMode = stream.chatService?.getPermissionMode();
|
|
310
|
+
if (chainFinalMode)
|
|
311
|
+
await persistSessionPermissionMode(sessionId, chainFinalMode);
|
|
312
|
+
const endProjectSlug = sessionProjectMap.get(sessionId);
|
|
313
|
+
if (endProjectSlug) {
|
|
314
|
+
triggerDashboardStatusChange(endProjectSlug);
|
|
315
|
+
sessionProjectMap.delete(sessionId);
|
|
316
|
+
}
|
|
317
|
+
// Continue draining or clean up — browser state is irrelevant
|
|
318
|
+
if (remaining && remaining.some(item => item.status === 'pending')) {
|
|
319
|
+
log.info(`[CHAIN-DRAIN] finally: scheduling next drain for sessionId=${sessionId}`);
|
|
320
|
+
scheduleChainDrain(sessionId, lang);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
log.info(`[CHAIN-DRAIN] finally: no more pending items, cleaning up chain for sessionId=${sessionId}`);
|
|
324
|
+
cleanupChainIfIdle(sessionId);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
log.warn(`[CHAIN-DRAIN] finally: stream is NOT the current active stream (replaced?): sessionId=${sessionId}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
// Stream creation failed — schedule retry or cleanup
|
|
333
|
+
log.warn(`[CHAIN-DRAIN] finally: no stream was created, scheduling retry: sessionId=${sessionId}`);
|
|
334
|
+
const remaining = chainState.get(sessionId);
|
|
335
|
+
if (remaining && remaining.some(item => item.status === 'pending')) {
|
|
336
|
+
scheduleChainDrain(sessionId, lang);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
cleanupChainIfIdle(sessionId);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}, 1000);
|
|
344
|
+
}
|
|
98
345
|
let permissionRequestCounter = 0;
|
|
99
346
|
/** Create a buffered emit function that buffers and broadcasts to all connected sockets */
|
|
100
347
|
function createStreamEmit(stream) {
|
|
@@ -132,12 +379,109 @@ export function isSessionStreaming(sessionId) {
|
|
|
132
379
|
const stream = activeStreams.get(sessionId);
|
|
133
380
|
return !!stream && stream.status === 'running';
|
|
134
381
|
}
|
|
135
|
-
/**
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
382
|
+
/** Completed stream buffers kept independently of activeStreams.
|
|
383
|
+
* When a stream completes, its buffer is saved here for 5 seconds so clients
|
|
384
|
+
* joining during the JSONL flush window can still receive the completed turn.
|
|
385
|
+
* This is separate from activeStreams so new streams can be created immediately
|
|
386
|
+
* without losing the completed buffer. */
|
|
387
|
+
const completedBuffers = new Map();
|
|
388
|
+
/** Timer handles for completedBuffer expiry, keyed by sessionId.
|
|
389
|
+
* Tracked so we can cancel the previous timer when a new buffer replaces it,
|
|
390
|
+
* allowing the old buffer to be GC'd immediately instead of waiting for expiry. */
|
|
391
|
+
const completedBufferTimers = new Map();
|
|
392
|
+
/** How long to keep completed buffers (ms). Allows JSONL to flush before
|
|
393
|
+
* fetchMessages becomes the sole source for this turn's data. */
|
|
394
|
+
const COMPLETED_BUFFER_TTL_MS = 5000;
|
|
395
|
+
/** Get the earliest stream start timestamp (active OR recently completed).
|
|
396
|
+
* When both exist (e.g., chain: previous turn completed + new turn running),
|
|
397
|
+
* returns the earlier one so fetchMessages excludes ALL stream-period messages.
|
|
398
|
+
* Both the completed turn and active turn are provided via buffer replay. */
|
|
399
|
+
export function getStreamStartedAt(sessionId) {
|
|
400
|
+
const stream = activeStreams.get(sessionId);
|
|
401
|
+
const runningStart = stream && stream.status === 'running' ? stream.startedAt : null;
|
|
402
|
+
const completedStart = completedBuffers.get(sessionId)?.startedAt ?? null;
|
|
403
|
+
if (runningStart && completedStart)
|
|
404
|
+
return Math.min(runningStart, completedStart);
|
|
405
|
+
return runningStart ?? completedStart;
|
|
406
|
+
}
|
|
407
|
+
/** Get start timestamp of the currently running stream only (ignores completed buffers). */
|
|
408
|
+
export function getRunningStreamStartedAt(sessionId) {
|
|
409
|
+
const stream = activeStreams.get(sessionId);
|
|
410
|
+
return stream && stream.status === 'running' ? stream.startedAt : null;
|
|
411
|
+
}
|
|
412
|
+
/** Get the completed buffer for a session (null if none or expired). */
|
|
413
|
+
export function getCompletedBuffer(sessionId) {
|
|
414
|
+
const completed = completedBuffers.get(sessionId);
|
|
415
|
+
return completed ? completed.events : null;
|
|
416
|
+
}
|
|
417
|
+
/** Clean up a stream from activeStreams immediately. Saves the buffer to
|
|
418
|
+
* completedBuffers for 5 seconds so it remains available independently
|
|
419
|
+
* of any new stream that may be created for the same session. */
|
|
420
|
+
function cleanupStream(streamKey, expectedStream) {
|
|
421
|
+
const current = activeStreams.get(streamKey);
|
|
422
|
+
// Identity guard: if caller specifies the expected stream but a replacement has
|
|
423
|
+
// taken over, don't delete activeStreams (would remove the new stream). However,
|
|
424
|
+
// still save the completed buffer so clients can replay the finished turn.
|
|
425
|
+
const replaced = expectedStream && current !== expectedStream;
|
|
426
|
+
const stream = expectedStream ?? current;
|
|
427
|
+
// Keep a reference to the completed buffer independently of activeStreams.
|
|
428
|
+
// No copy needed — the buffer is immutable after completion (createStreamEmit
|
|
429
|
+
// only pushes to running streams). Only the buffer array is retained; the rest
|
|
430
|
+
// of the stream object (sockets, chatService, etc.) is released for GC.
|
|
431
|
+
if (stream && stream.buffer.length > 0) {
|
|
432
|
+
// Only write if this stream is newer than (or same as) any existing entry.
|
|
433
|
+
// An older stream's delayed finalizeStream must not overwrite a newer buffer.
|
|
434
|
+
const existing = completedBuffers.get(streamKey);
|
|
435
|
+
if (!existing || stream.startedAt >= existing.startedAt) {
|
|
436
|
+
// Cancel previous expiry timer so the old buffer can be GC'd immediately
|
|
437
|
+
const prevTimer = completedBufferTimers.get(streamKey);
|
|
438
|
+
if (prevTimer)
|
|
439
|
+
clearTimeout(prevTimer);
|
|
440
|
+
completedBuffers.set(streamKey, {
|
|
441
|
+
events: stream.buffer,
|
|
442
|
+
startedAt: stream.startedAt,
|
|
443
|
+
});
|
|
444
|
+
const timer = setTimeout(() => {
|
|
445
|
+
// Guard: only delete if this timer is still the current one for this key.
|
|
446
|
+
if (completedBufferTimers.get(streamKey) === timer) {
|
|
447
|
+
completedBuffers.delete(streamKey);
|
|
448
|
+
completedBufferTimers.delete(streamKey);
|
|
449
|
+
}
|
|
450
|
+
}, COMPLETED_BUFFER_TTL_MS);
|
|
451
|
+
completedBufferTimers.set(streamKey, timer);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Only delete from activeStreams and clean up socket mappings if the stream
|
|
455
|
+
// hasn't been replaced. When replaced, the new stream owns those resources.
|
|
456
|
+
if (!replaced) {
|
|
457
|
+
activeStreams.delete(streamKey);
|
|
458
|
+
for (const [sockId, sessId] of socketToSession.entries()) {
|
|
459
|
+
if (sessId === streamKey)
|
|
460
|
+
socketToSession.delete(sockId);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/** Normalize legacy 'never' sync policy to 'streaming' */
|
|
465
|
+
function normalizeSyncPolicy(policy) {
|
|
466
|
+
return policy === 'always' ? 'always' : 'streaming';
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Persist the stream's final permission mode to .hammoc/session-permissions.json.
|
|
470
|
+
* Returns a promise that resolves when persistence is complete (or fails silently).
|
|
471
|
+
* Must be called before sessionProjectMap.delete() for the given sessionId.
|
|
472
|
+
*/
|
|
473
|
+
async function persistSessionPermissionMode(sessionId, mode, fallbackSlug) {
|
|
474
|
+
const slug = sessionProjectMap.get(sessionId) || fallbackSlug;
|
|
475
|
+
if (!slug)
|
|
476
|
+
return;
|
|
477
|
+
try {
|
|
478
|
+
const projectPath = await projectService.resolveProjectPath(slug);
|
|
479
|
+
if (projectPath) {
|
|
480
|
+
await projectService.updateSessionPermission(projectPath, sessionId, mode);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
log.error('Failed to persist session permission mode:', err);
|
|
141
485
|
}
|
|
142
486
|
}
|
|
143
487
|
/**
|
|
@@ -203,27 +547,52 @@ export function rekeyStream(stream, newSessionId) {
|
|
|
203
547
|
* Mark a stream as completed and broadcast stream-change.
|
|
204
548
|
* Cleans up from activeStreams map.
|
|
205
549
|
*/
|
|
206
|
-
export function finalizeStream(sessionId) {
|
|
550
|
+
export async function finalizeStream(sessionId) {
|
|
207
551
|
const stream = activeStreams.get(sessionId);
|
|
208
552
|
if (stream) {
|
|
553
|
+
const finalMode = stream.chatService?.getPermissionMode();
|
|
554
|
+
if (finalMode)
|
|
555
|
+
await persistSessionPermissionMode(sessionId, finalMode);
|
|
209
556
|
stream.status = 'completed';
|
|
210
|
-
cleanupStream
|
|
557
|
+
// Pass stream reference so cleanupStream won't accidentally clean up a
|
|
558
|
+
// replacement stream that started during the async persistence above.
|
|
559
|
+
cleanupStream(sessionId, stream);
|
|
211
560
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
561
|
+
// Only emit inactive status and clear project mapping if a replacement stream
|
|
562
|
+
// hasn't taken over during the async persistence above. Without this guard,
|
|
563
|
+
// a new running stream would be falsely reported as inactive.
|
|
564
|
+
// Re-read slug at use time to avoid ABA race with stale capture.
|
|
565
|
+
const currentStream = activeStreams.get(sessionId);
|
|
566
|
+
if (!currentStream || currentStream.status !== 'running') {
|
|
567
|
+
const freshSlug = sessionProjectMap.get(sessionId);
|
|
568
|
+
emitStreamChange(sessionId, false, freshSlug ?? null);
|
|
569
|
+
if (freshSlug) {
|
|
570
|
+
sessionProjectMap.delete(sessionId);
|
|
571
|
+
triggerDashboardStatusChange(freshSlug);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Emit session:stream-change scoped to the project room when projectSlug is known,
|
|
577
|
+
* falling back to global broadcast otherwise.
|
|
578
|
+
*/
|
|
579
|
+
function emitStreamChange(sessionId, active, projectSlug) {
|
|
580
|
+
const payload = { sessionId, active, projectSlug };
|
|
581
|
+
if (projectSlug) {
|
|
582
|
+
io.to(`project:${projectSlug}`).emit('session:stream-change', payload);
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
io.emit('session:stream-change', payload);
|
|
217
586
|
}
|
|
218
587
|
}
|
|
219
588
|
/**
|
|
220
|
-
* Broadcast session:stream-change to all
|
|
589
|
+
* Broadcast session:stream-change to project room (or all clients as fallback).
|
|
221
590
|
* Used by queue service to signal stream start/end.
|
|
222
591
|
* Story 20.1: Also triggers dashboard status change when projectSlug is known.
|
|
223
592
|
*/
|
|
224
593
|
export function broadcastStreamChange(sessionId, active) {
|
|
225
|
-
io.emit('session:stream-change', { sessionId, active });
|
|
226
594
|
const slug = sessionProjectMap.get(sessionId);
|
|
595
|
+
emitStreamChange(sessionId, active, slug ?? null);
|
|
227
596
|
if (slug) {
|
|
228
597
|
triggerDashboardStatusChange(slug);
|
|
229
598
|
if (!active)
|
|
@@ -256,7 +625,7 @@ function matchAcceptLanguage(header) {
|
|
|
256
625
|
*/
|
|
257
626
|
export async function initializeWebSocket(httpServer) {
|
|
258
627
|
io = new SocketIOServer(httpServer, {
|
|
259
|
-
cors: config.
|
|
628
|
+
cors: config.cors,
|
|
260
629
|
maxHttpBufferSize: 100 * 1024 * 1024, // 100MB for base64 image payloads
|
|
261
630
|
});
|
|
262
631
|
// Session middleware for WebSocket (Story 2.5 - Task 4)
|
|
@@ -302,7 +671,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
302
671
|
try {
|
|
303
672
|
const clientIP = extractClientIP(socket);
|
|
304
673
|
const isLocal = isLocalIP(clientIP);
|
|
305
|
-
const terminalEnabled =
|
|
674
|
+
const terminalEnabled = preferencesService.getTerminalEnabled();
|
|
306
675
|
socket.emit('terminal:access', {
|
|
307
676
|
allowed: terminalEnabled && isLocal,
|
|
308
677
|
enabled: terminalEnabled,
|
|
@@ -387,6 +756,8 @@ export async function initializeWebSocket(httpServer) {
|
|
|
387
756
|
startedAt: Date.now(),
|
|
388
757
|
};
|
|
389
758
|
activeStreams.set(streamKey, stream);
|
|
759
|
+
// Bump drain generation so any pending scheduled drain is invalidated
|
|
760
|
+
chainDrainGeneration.set(streamKey, (chainDrainGeneration.get(streamKey) || 0) + 1);
|
|
390
761
|
for (const sock of initialSockets) {
|
|
391
762
|
socketToSession.set(sock.id, streamKey);
|
|
392
763
|
}
|
|
@@ -400,23 +771,50 @@ export async function initializeWebSocket(httpServer) {
|
|
|
400
771
|
}
|
|
401
772
|
}).catch(() => { });
|
|
402
773
|
try {
|
|
403
|
-
await handleChatSend(stream, data, abortController, lang);
|
|
774
|
+
const sendSuccess = await handleChatSend(stream, data, abortController, lang);
|
|
775
|
+
if (sendSuccess)
|
|
776
|
+
chainResumableSessions.add(stream.sessionId);
|
|
404
777
|
}
|
|
405
778
|
finally {
|
|
406
779
|
stream.status = 'completed';
|
|
407
780
|
const endedSessionId = stream.sessionId;
|
|
781
|
+
const isCurrentStream = activeStreams.get(endedSessionId) === stream;
|
|
782
|
+
log.info(`[CHAIN-DRAIN] chat:send finally: endedSessionId=${endedSessionId}, isCurrentStream=${isCurrentStream}, socketsOnStream=${stream.sockets.size}`);
|
|
408
783
|
// Only cleanup if this stream is still the active one for this session.
|
|
409
784
|
// A replacement stream (from another chat:send) may have already taken over
|
|
410
785
|
// the same key — deleting it would be a race condition.
|
|
411
|
-
if (
|
|
786
|
+
if (isCurrentStream) {
|
|
787
|
+
const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
|
|
412
788
|
cleanupStream(endedSessionId);
|
|
413
|
-
|
|
789
|
+
emitStreamChange(endedSessionId, false, sendEndSlug);
|
|
790
|
+
// Persist per-session permission mode before cleanup
|
|
791
|
+
const sendFinalMode = stream.chatService?.getPermissionMode();
|
|
792
|
+
if (sendFinalMode)
|
|
793
|
+
await persistSessionPermissionMode(endedSessionId, sendFinalMode);
|
|
414
794
|
// Story 20.1: Trigger dashboard status change on stream end
|
|
415
795
|
const endProjectSlug = sessionProjectMap.get(endedSessionId);
|
|
416
796
|
if (endProjectSlug) {
|
|
797
|
+
// Update sessions-index.json so future list queries hit cache
|
|
798
|
+
new SessionService().updateSessionIndex(endProjectSlug, endedSessionId).catch((err) => {
|
|
799
|
+
log.warn(`Failed to update session index: project=${endProjectSlug} session=${endedSessionId}`, err);
|
|
800
|
+
});
|
|
417
801
|
triggerDashboardStatusChange(endProjectSlug);
|
|
418
802
|
sessionProjectMap.delete(endedSessionId);
|
|
419
803
|
}
|
|
804
|
+
// Story 24.1: Schedule chain drain if pending items exist (browser-independent)
|
|
805
|
+
const pendingChain = chainState.get(endedSessionId);
|
|
806
|
+
const pendingCount = pendingChain?.filter(item => item.status === 'pending').length ?? 0;
|
|
807
|
+
log.info(`[CHAIN-DRAIN] chat:send finally: chainItems=${pendingChain?.length ?? 0}, pendingCount=${pendingCount}, statuses=${JSON.stringify(pendingChain?.map(i => ({ id: i.id.slice(0, 8), status: i.status })) ?? [])}`);
|
|
808
|
+
if (pendingChain && pendingChain.some(item => item.status === 'pending')) {
|
|
809
|
+
scheduleChainDrain(endedSessionId, lang);
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
log.info(`[CHAIN-DRAIN] chat:send finally: no pending chain items, calling cleanupChainIfIdle for ${endedSessionId}`);
|
|
813
|
+
cleanupChainIfIdle(endedSessionId);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
log.warn(`[CHAIN-DRAIN] chat:send finally: stream is NOT current active stream (replaced?): endedSessionId=${endedSessionId}`);
|
|
420
818
|
}
|
|
421
819
|
}
|
|
422
820
|
});
|
|
@@ -430,17 +828,16 @@ export async function initializeWebSocket(httpServer) {
|
|
|
430
828
|
stream.pendingPermissions.get(data.requestId).resolve({ approved: data.approved, response: data.response });
|
|
431
829
|
stream.pendingPermissions.delete(data.requestId);
|
|
432
830
|
// Broadcast the actual resolution to all OTHER viewers so their
|
|
433
|
-
// tool/interactive cards can show the correct approve/deny state
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
831
|
+
// tool/interactive cards can show the correct approve/deny state.
|
|
832
|
+
// Also buffer via createStreamEmit so reconnecting clients see the
|
|
833
|
+
// resolved state instead of a stale 'waiting' permission card.
|
|
834
|
+
const emit = createStreamEmit(stream);
|
|
835
|
+
emit('permission:resolved', {
|
|
836
|
+
requestId: data.requestId,
|
|
837
|
+
approved: data.approved,
|
|
838
|
+
interactionType: data.interactionType,
|
|
839
|
+
response: data.response,
|
|
840
|
+
});
|
|
444
841
|
}
|
|
445
842
|
else {
|
|
446
843
|
// Permission already resolved by another viewer — notify sender
|
|
@@ -462,13 +859,35 @@ export async function initializeWebSocket(httpServer) {
|
|
|
462
859
|
}
|
|
463
860
|
stream.abortController.abort('user-abort');
|
|
464
861
|
}
|
|
862
|
+
// Also clear any pending prompt chain — user expects everything to stop.
|
|
863
|
+
// Always bump generation to invalidate any in-flight drain timers,
|
|
864
|
+
// even if chainState is already empty (stale timer edge case).
|
|
865
|
+
const gen = (chainDrainGeneration.get(sessionId) || 0) + 1;
|
|
866
|
+
chainDrainGeneration.set(sessionId, gen);
|
|
867
|
+
const chainItems = chainState.get(sessionId);
|
|
868
|
+
if (chainItems && chainItems.length > 0) {
|
|
869
|
+
chainState.set(sessionId, []);
|
|
870
|
+
broadcastChainUpdate(sessionId);
|
|
871
|
+
// Do NOT call cleanupChainIfIdle here — the active stream still exists
|
|
872
|
+
// and scheduleChainDrain's finally block will handle cleanup after it completes.
|
|
873
|
+
clearPersistedFailures(sessionId)
|
|
874
|
+
.then(() => {
|
|
875
|
+
// Guard: only broadcast if no new chain was started since this abort
|
|
876
|
+
if (chainDrainGeneration.get(sessionId) === gen) {
|
|
877
|
+
broadcastChainUpdate(sessionId);
|
|
878
|
+
}
|
|
879
|
+
})
|
|
880
|
+
.catch((err) => {
|
|
881
|
+
log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
|
|
882
|
+
});
|
|
883
|
+
}
|
|
465
884
|
});
|
|
466
885
|
// Handle permission:mode-change — update SDK permission mode and broadcast to viewers
|
|
467
886
|
socket.on('permission:mode-change', async (data) => {
|
|
468
|
-
const sessionId = socketToSession.get(socket.id);
|
|
887
|
+
const sessionId = socketToSession.get(socket.id) || socketSessionRoom.get(socket.id);
|
|
469
888
|
if (!sessionId)
|
|
470
889
|
return;
|
|
471
|
-
const { mode,
|
|
890
|
+
const { mode, projectSlug } = data;
|
|
472
891
|
const stream = activeStreams.get(sessionId);
|
|
473
892
|
// 1) Update SDK permission mode — only when stream is actively running
|
|
474
893
|
if (stream?.chatService && stream.status === 'running') {
|
|
@@ -478,11 +897,32 @@ export async function initializeWebSocket(httpServer) {
|
|
|
478
897
|
}
|
|
479
898
|
catch (err) {
|
|
480
899
|
log.error('Failed to change permission mode:', err);
|
|
900
|
+
return; // Don't persist or broadcast a mode that failed to apply
|
|
481
901
|
}
|
|
482
902
|
}
|
|
483
|
-
//
|
|
484
|
-
|
|
485
|
-
|
|
903
|
+
// Update pending chain items so the next drain uses the new mode.
|
|
904
|
+
// Outside the running-stream block because mode can change between turns
|
|
905
|
+
// (e.g., during the 1s chain drain delay when no stream is active).
|
|
906
|
+
const chainItems = chainState.get(sessionId);
|
|
907
|
+
if (chainItems) {
|
|
908
|
+
for (const item of chainItems) {
|
|
909
|
+
if (item.status === 'pending' || item.status === 'sending') {
|
|
910
|
+
item.permissionMode = mode;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
// 2) Always persist per-session permission mode (read only when policy is 'always')
|
|
915
|
+
// Use projectSlug from client as fallback when sessionProjectMap entry is gone (stream ended)
|
|
916
|
+
await persistSessionPermissionMode(sessionId, mode, projectSlug);
|
|
917
|
+
// 3) Broadcast to other viewers based on sync policy
|
|
918
|
+
let syncPolicy = 'streaming';
|
|
919
|
+
try {
|
|
920
|
+
const prefs = await preferencesService.readPreferences();
|
|
921
|
+
syncPolicy = normalizeSyncPolicy(prefs.permissionSyncPolicy);
|
|
922
|
+
}
|
|
923
|
+
catch (err) {
|
|
924
|
+
log.error('Failed to read preferences for sync policy:', err);
|
|
925
|
+
}
|
|
486
926
|
if (syncPolicy === 'streaming' && stream?.status !== 'running')
|
|
487
927
|
return;
|
|
488
928
|
// 'always' or ('streaming' + running) → broadcast via Socket.io room
|
|
@@ -490,7 +930,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
490
930
|
});
|
|
491
931
|
// Handle session:join event — attach socket to active running stream (broadcast)
|
|
492
932
|
// Also joins a persistent Socket.io room so future streams auto-include this socket.
|
|
493
|
-
socket.on('session:join', (sessionId) => {
|
|
933
|
+
socket.on('session:join', (sessionId, projectSlug) => {
|
|
494
934
|
// Detach this socket from any previously-attached stream to prevent
|
|
495
935
|
// events from the old stream leaking to a different session's listeners
|
|
496
936
|
const prevSessionId = socketToSession.get(socket.id);
|
|
@@ -502,22 +942,124 @@ export async function initializeWebSocket(httpServer) {
|
|
|
502
942
|
socketToSession.delete(socket.id);
|
|
503
943
|
socket.leave(`session:${prevSessionId}`);
|
|
504
944
|
}
|
|
945
|
+
// Also leave previous session room even when no active stream existed
|
|
946
|
+
// (socketToSession is only set when a stream is running)
|
|
947
|
+
const prevRoomSessionId = socketSessionRoom.get(socket.id);
|
|
948
|
+
if (prevRoomSessionId && prevRoomSessionId !== sessionId && prevRoomSessionId !== prevSessionId) {
|
|
949
|
+
socket.leave(`session:${prevRoomSessionId}`);
|
|
950
|
+
}
|
|
505
951
|
// Join persistent session room (survives beyond ActiveStream lifecycle)
|
|
506
952
|
socket.join(`session:${sessionId}`);
|
|
953
|
+
// Leave previous project room if switching projects (or if new join has no projectSlug)
|
|
954
|
+
const prevProjectRoom = socketProjectRoom.get(socket.id);
|
|
955
|
+
if (prevProjectRoom && prevProjectRoom !== projectSlug) {
|
|
956
|
+
socket.leave(`project:${prevProjectRoom}`);
|
|
957
|
+
socketProjectRoom.delete(socket.id);
|
|
958
|
+
}
|
|
959
|
+
// Join project room so scoped events (e.g., session:stream-change) are received
|
|
960
|
+
if (projectSlug) {
|
|
961
|
+
socket.join(`project:${projectSlug}`);
|
|
962
|
+
socketProjectRoom.set(socket.id, projectSlug);
|
|
963
|
+
}
|
|
964
|
+
// Story 24.3: Track session room membership for session:leave room management
|
|
965
|
+
socketSessionRoom.set(socket.id, sessionId);
|
|
507
966
|
const stream = activeStreams.get(sessionId);
|
|
967
|
+
// Story 24.1: Send current chain state on join (in-memory + persisted failures)
|
|
968
|
+
// Only attempt disk read for valid UUID sessionIds to avoid unbounded lock map growth
|
|
969
|
+
if (UUID_RE.test(sessionId)) {
|
|
970
|
+
withChainFailureLock(sessionId, async () => {
|
|
971
|
+
return projectService.readChainFailures(sessionId);
|
|
972
|
+
}).then(failures => {
|
|
973
|
+
// Re-read in-memory state now (may have changed during async disk read)
|
|
974
|
+
const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
|
|
975
|
+
const allItems = [...freshItems, ...failures];
|
|
976
|
+
socket.emit('chain:update', { sessionId, items: allItems });
|
|
977
|
+
}).catch((err) => {
|
|
978
|
+
log.error(`Failed to read chain failures on join (session ${sessionId}):`, err);
|
|
979
|
+
const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
|
|
980
|
+
socket.emit('chain:update', { sessionId, items: freshItems });
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
// Non-UUID session: only send in-memory chain state (no disk persistence)
|
|
985
|
+
const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
|
|
986
|
+
socket.emit('chain:update', { sessionId, items: freshItems });
|
|
987
|
+
}
|
|
508
988
|
if (!stream || stream.status !== 'running') {
|
|
509
|
-
|
|
989
|
+
// Emit inactive status + completed buffer replay.
|
|
990
|
+
// Wrapped in a helper that re-checks activeStreams because the async
|
|
991
|
+
// preference-read path can yield, and a new stream may start in between.
|
|
992
|
+
const emitInactiveWithReplay = (permissionMode) => {
|
|
993
|
+
// Stale callback guard: if the socket has left this session (moved to
|
|
994
|
+
// another session or disconnected), don't emit anything for the old session.
|
|
995
|
+
if (socketSessionRoom.get(socket.id) !== sessionId || !socket.connected) {
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
// Re-check: if a running stream appeared during async wait, emit active
|
|
999
|
+
// state instead of stale inactive. Without this, the client would miss
|
|
1000
|
+
// the initial stream:status/buffer-replay for the new stream.
|
|
1001
|
+
const freshStream = activeStreams.get(sessionId);
|
|
1002
|
+
if (freshStream && freshStream.status === 'running') {
|
|
1003
|
+
socketToSession.set(socket.id, sessionId);
|
|
1004
|
+
const bufSnapshot = [...freshStream.buffer];
|
|
1005
|
+
const freshMode = freshStream.chatService?.getPermissionMode();
|
|
1006
|
+
socket.emit('stream:status', { active: true, sessionId, permissionMode: freshMode });
|
|
1007
|
+
const completedBuf = getCompletedBuffer(sessionId);
|
|
1008
|
+
if (completedBuf) {
|
|
1009
|
+
socket.emit('stream:buffer-replay', { sessionId, events: completedBuf });
|
|
1010
|
+
}
|
|
1011
|
+
socket.emit('stream:buffer-replay', { sessionId, events: bufSnapshot });
|
|
1012
|
+
freshStream.sockets.add(socket);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
socket.emit('stream:status', { active: false, sessionId, permissionMode });
|
|
1016
|
+
// Replay recently completed stream buffer so the client has the finished turn
|
|
1017
|
+
const completed = getCompletedBuffer(sessionId);
|
|
1018
|
+
if (completed) {
|
|
1019
|
+
socket.emit('stream:buffer-replay', { sessionId, events: completed });
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
// For 'always' sync policy, restore per-session permission mode from disk
|
|
1023
|
+
const resolvedSlug = projectSlug || sessionProjectMap.get(sessionId);
|
|
1024
|
+
if (resolvedSlug && UUID_RE.test(sessionId)) {
|
|
1025
|
+
preferencesService.readPreferences().then(async (prefs) => {
|
|
1026
|
+
if (normalizeSyncPolicy(prefs.permissionSyncPolicy) === 'always') {
|
|
1027
|
+
const projectPath = await projectService.resolveProjectPath(resolvedSlug);
|
|
1028
|
+
if (projectPath) {
|
|
1029
|
+
const perms = await projectService.readSessionPermissions(projectPath);
|
|
1030
|
+
const savedMode = perms[sessionId];
|
|
1031
|
+
emitInactiveWithReplay(savedMode);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
emitInactiveWithReplay();
|
|
1036
|
+
}).catch(() => {
|
|
1037
|
+
emitInactiveWithReplay();
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
emitInactiveWithReplay();
|
|
1042
|
+
}
|
|
510
1043
|
return;
|
|
511
1044
|
}
|
|
512
|
-
// Add socket to broadcast set (multiple browsers can watch simultaneously)
|
|
513
|
-
stream.sockets.add(socket);
|
|
514
1045
|
socketToSession.set(socket.id, sessionId);
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1046
|
+
// Snapshot BEFORE adding socket to broadcast set to prevent race.
|
|
1047
|
+
const bufferSnapshot = [...stream.buffer];
|
|
1048
|
+
const permissionMode = stream.chatService?.getPermissionMode();
|
|
1049
|
+
// Emit order matters for the client:
|
|
1050
|
+
// 1. stream:status { active: true } → client calls restoreStreaming + trimMessagesAfterLastUser
|
|
1051
|
+
// 2. completed buffer replay → client calls addMessages (trim already ran, won't remove these)
|
|
1052
|
+
// 3. active buffer replay → client sets streaming segments for the current turn
|
|
1053
|
+
socket.emit('stream:status', { active: true, sessionId, permissionMode });
|
|
1054
|
+
// Replay recently completed stream buffer (e.g., previous chain turn) AFTER
|
|
1055
|
+
// stream:status so the client has already trimmed stale messages.
|
|
1056
|
+
const completedBuf = getCompletedBuffer(sessionId);
|
|
1057
|
+
if (completedBuf) {
|
|
1058
|
+
socket.emit('stream:buffer-replay', { sessionId, events: completedBuf });
|
|
520
1059
|
}
|
|
1060
|
+
socket.emit('stream:buffer-replay', { sessionId, events: bufferSnapshot });
|
|
1061
|
+
// NOW add to broadcast set — live events flow from here
|
|
1062
|
+
stream.sockets.add(socket);
|
|
521
1063
|
});
|
|
522
1064
|
// Handle session:leave event — detach socket from current stream and session room
|
|
523
1065
|
// (client navigating away from a session while streaming continues in background)
|
|
@@ -530,12 +1072,133 @@ export async function initializeWebSocket(httpServer) {
|
|
|
530
1072
|
}
|
|
531
1073
|
socketToSession.delete(socket.id);
|
|
532
1074
|
}
|
|
533
|
-
// Use prevSessionId as fallback — client may send empty
|
|
534
|
-
// the sessionId is not available at unmount time (e.g., ChatPage cleanup)
|
|
535
|
-
const roomSessionId = sessionId || prevSessionId;
|
|
1075
|
+
// Use prevSessionId / socketSessionRoom as fallback — client may send empty
|
|
1076
|
+
// string when the sessionId is not available at unmount time (e.g., ChatPage cleanup)
|
|
1077
|
+
const roomSessionId = sessionId || prevSessionId || socketSessionRoom.get(socket.id);
|
|
536
1078
|
if (roomSessionId) {
|
|
537
1079
|
socket.leave(`session:${roomSessionId}`);
|
|
538
1080
|
}
|
|
1081
|
+
// Story 24.3: Clean up session room tracking on leave
|
|
1082
|
+
// Only clear project room if the leaving session matches current tracking.
|
|
1083
|
+
// Prevents race where a new session:join overwrites tracking before old leave arrives.
|
|
1084
|
+
const trackedSession = socketSessionRoom.get(socket.id);
|
|
1085
|
+
if (!trackedSession || trackedSession === roomSessionId) {
|
|
1086
|
+
socketSessionRoom.delete(socket.id);
|
|
1087
|
+
const prevProjectSlug = socketProjectRoom.get(socket.id);
|
|
1088
|
+
if (prevProjectSlug) {
|
|
1089
|
+
socket.leave(`project:${prevProjectSlug}`);
|
|
1090
|
+
}
|
|
1091
|
+
socketProjectRoom.delete(socket.id);
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
// Story 24.1: Prompt chain event handlers
|
|
1095
|
+
socket.on('chain:add', (data) => {
|
|
1096
|
+
if (!data || typeof data !== 'object')
|
|
1097
|
+
return;
|
|
1098
|
+
const { sessionId, content, workingDirectory, permissionMode, model } = data;
|
|
1099
|
+
const lang = socket.data.language || 'en';
|
|
1100
|
+
const t = i18next.getFixedT(lang);
|
|
1101
|
+
// Input validation (UUID required for disk persistence compatibility)
|
|
1102
|
+
if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
|
|
1103
|
+
return;
|
|
1104
|
+
if (!content || typeof content !== 'string' || !content.trim())
|
|
1105
|
+
return;
|
|
1106
|
+
if (!workingDirectory || typeof workingDirectory !== 'string')
|
|
1107
|
+
return;
|
|
1108
|
+
if (content.length > 100_000) {
|
|
1109
|
+
socket.emit('error', { code: ERROR_CODES.CHAT_ERROR, message: t('ws.error.chainContentTooLong') });
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
// Validate socket is a member of the session room
|
|
1113
|
+
if (!socket.rooms.has(`session:${sessionId}`))
|
|
1114
|
+
return;
|
|
1115
|
+
const items = chainState.get(sessionId) || [];
|
|
1116
|
+
if (items.length >= 10) {
|
|
1117
|
+
socket.emit('error', {
|
|
1118
|
+
code: ERROR_CODES.CHAIN_MAX_EXCEEDED,
|
|
1119
|
+
message: t('ws.error.chainMaxExceeded'),
|
|
1120
|
+
});
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const item = {
|
|
1124
|
+
id: generateChainItemId(),
|
|
1125
|
+
content,
|
|
1126
|
+
status: 'pending',
|
|
1127
|
+
createdAt: Date.now(),
|
|
1128
|
+
workingDirectory,
|
|
1129
|
+
permissionMode,
|
|
1130
|
+
model,
|
|
1131
|
+
};
|
|
1132
|
+
items.push(item);
|
|
1133
|
+
chainState.set(sessionId, items);
|
|
1134
|
+
broadcastChainUpdate(sessionId);
|
|
1135
|
+
// If no active stream, trigger drain so items don't stay pending indefinitely
|
|
1136
|
+
if (!activeStreams.has(sessionId)) {
|
|
1137
|
+
scheduleChainDrain(sessionId, lang);
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
socket.on('chain:remove', (data) => {
|
|
1141
|
+
if (!data || typeof data !== 'object')
|
|
1142
|
+
return;
|
|
1143
|
+
const { sessionId, id } = data;
|
|
1144
|
+
if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
|
|
1145
|
+
return;
|
|
1146
|
+
if (!id || typeof id !== 'string')
|
|
1147
|
+
return;
|
|
1148
|
+
if (!socket.rooms.has(`session:${sessionId}`))
|
|
1149
|
+
return;
|
|
1150
|
+
const items = chainState.get(sessionId);
|
|
1151
|
+
if (items) {
|
|
1152
|
+
// If the removed item is currently sending, abort its active stream
|
|
1153
|
+
const removedItem = items.find(item => item.id === id);
|
|
1154
|
+
if (removedItem?.status === 'sending') {
|
|
1155
|
+
const stream = activeStreams.get(sessionId);
|
|
1156
|
+
if (stream && stream.status === 'running') {
|
|
1157
|
+
stream.abortController.abort('chain-item-removed');
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
const filtered = items.filter(item => item.id !== id);
|
|
1161
|
+
chainState.set(sessionId, filtered);
|
|
1162
|
+
broadcastChainUpdate(sessionId);
|
|
1163
|
+
if (filtered.length === 0) {
|
|
1164
|
+
cleanupChainIfIdle(sessionId);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
// Also remove from persisted failures (dismiss)
|
|
1168
|
+
removePersistedFailure(sessionId, id).catch((err) => {
|
|
1169
|
+
log.error(`Failed to remove persisted failure ${id} for session ${sessionId}:`, err);
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
1172
|
+
socket.on('chain:clear', (data) => {
|
|
1173
|
+
if (!data || typeof data !== 'object')
|
|
1174
|
+
return;
|
|
1175
|
+
const { sessionId } = data;
|
|
1176
|
+
if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
|
|
1177
|
+
return;
|
|
1178
|
+
// Validate socket is a member of the session room
|
|
1179
|
+
if (!socket.rooms.has(`session:${sessionId}`))
|
|
1180
|
+
return;
|
|
1181
|
+
// If any item is currently sending, abort its active stream
|
|
1182
|
+
const items = chainState.get(sessionId);
|
|
1183
|
+
if (items?.some(item => item.status === 'sending')) {
|
|
1184
|
+
const stream = activeStreams.get(sessionId);
|
|
1185
|
+
if (stream && stream.status === 'running') {
|
|
1186
|
+
stream.abortController.abort('chain-cleared');
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
chainState.set(sessionId, []);
|
|
1190
|
+
// Bump generation instead of deleting — prevents stale timers from matching
|
|
1191
|
+
// a reset counter value after clear + re-add sequence
|
|
1192
|
+
chainDrainGeneration.set(sessionId, (chainDrainGeneration.get(sessionId) || 0) + 1);
|
|
1193
|
+
broadcastChainUpdate(sessionId);
|
|
1194
|
+
cleanupChainIfIdle(sessionId);
|
|
1195
|
+
// Clear persisted failures from disk, then broadcast final consistent state
|
|
1196
|
+
// (prevents stale failures from reappearing via concurrent broadcastChainUpdateWithFailures)
|
|
1197
|
+
clearPersistedFailures(sessionId)
|
|
1198
|
+
.then(() => broadcastChainUpdate(sessionId))
|
|
1199
|
+
.catch((err) => {
|
|
1200
|
+
log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
|
|
1201
|
+
});
|
|
539
1202
|
});
|
|
540
1203
|
// Story 20.1: Dashboard subscribe/unsubscribe
|
|
541
1204
|
socket.on('dashboard:subscribe', () => {
|
|
@@ -610,7 +1273,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
610
1273
|
const lang = socket.data.language || 'en';
|
|
611
1274
|
const t = i18next.getFixedT(lang);
|
|
612
1275
|
// Story 17.5: Security guard
|
|
613
|
-
const access =
|
|
1276
|
+
const access = checkTerminalAccess(socket, lang);
|
|
614
1277
|
if (!access.allowed) {
|
|
615
1278
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:create`);
|
|
616
1279
|
socket.emit('terminal:error', access.error);
|
|
@@ -673,7 +1336,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
673
1336
|
});
|
|
674
1337
|
socket.on('terminal:input', async (data) => {
|
|
675
1338
|
// Story 17.5: Security guard
|
|
676
|
-
const inputAccess =
|
|
1339
|
+
const inputAccess = checkTerminalAccess(socket, socket.data.language || 'en');
|
|
677
1340
|
if (!inputAccess.allowed) {
|
|
678
1341
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:input`);
|
|
679
1342
|
socket.emit('terminal:error', { ...inputAccess.error, terminalId: data.terminalId });
|
|
@@ -689,7 +1352,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
689
1352
|
});
|
|
690
1353
|
socket.on('terminal:resize', async (data) => {
|
|
691
1354
|
// Story 17.5: Security guard
|
|
692
|
-
const resizeAccess =
|
|
1355
|
+
const resizeAccess = checkTerminalAccess(socket, socket.data.language || 'en');
|
|
693
1356
|
if (!resizeAccess.allowed) {
|
|
694
1357
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:resize`);
|
|
695
1358
|
socket.emit('terminal:error', { ...resizeAccess.error, terminalId: data.terminalId });
|
|
@@ -705,7 +1368,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
705
1368
|
});
|
|
706
1369
|
socket.on('terminal:list', async (data) => {
|
|
707
1370
|
const lang = socket.data.language || 'en';
|
|
708
|
-
const access =
|
|
1371
|
+
const access = checkTerminalAccess(socket, lang);
|
|
709
1372
|
if (!access.allowed) {
|
|
710
1373
|
socket.emit('terminal:list', { projectSlug: data.projectSlug, terminals: [] });
|
|
711
1374
|
return;
|
|
@@ -738,7 +1401,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
738
1401
|
});
|
|
739
1402
|
socket.on('terminal:close', async (data) => {
|
|
740
1403
|
// Story 17.5: Security guard
|
|
741
|
-
const closeAccess =
|
|
1404
|
+
const closeAccess = checkTerminalAccess(socket, socket.data.language || 'en');
|
|
742
1405
|
if (!closeAccess.allowed) {
|
|
743
1406
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:close`);
|
|
744
1407
|
socket.emit('terminal:error', { ...closeAccess.error, terminalId: data.terminalId });
|
|
@@ -768,6 +1431,8 @@ export async function initializeWebSocket(httpServer) {
|
|
|
768
1431
|
}
|
|
769
1432
|
socketToSession.delete(socket.id);
|
|
770
1433
|
}
|
|
1434
|
+
socketSessionRoom.delete(socket.id);
|
|
1435
|
+
socketProjectRoom.delete(socket.id);
|
|
771
1436
|
// PTY sessions are NOT cleaned up on socket disconnect.
|
|
772
1437
|
// They persist until explicitly closed by the user, the PTY process exits,
|
|
773
1438
|
// or the server shuts down. This prevents losing long-running terminal
|
|
@@ -833,7 +1498,8 @@ function isSessionNotFoundError(error) {
|
|
|
833
1498
|
return (message.includes('session not found') ||
|
|
834
1499
|
message.includes('session does not exist') ||
|
|
835
1500
|
message.includes('invalid session') ||
|
|
836
|
-
message.includes('no such session')
|
|
1501
|
+
message.includes('no such session') ||
|
|
1502
|
+
message.includes('no conversation found'));
|
|
837
1503
|
}
|
|
838
1504
|
/**
|
|
839
1505
|
* Handle chat:send event from client
|
|
@@ -852,7 +1518,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
852
1518
|
code: ERROR_CODES.VALIDATION_ERROR,
|
|
853
1519
|
message: validation.error,
|
|
854
1520
|
});
|
|
855
|
-
return;
|
|
1521
|
+
return false;
|
|
856
1522
|
}
|
|
857
1523
|
}
|
|
858
1524
|
// Validate workingDirectory exists
|
|
@@ -861,11 +1527,18 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
861
1527
|
code: ERROR_CODES.INVALID_WORKING_DIR,
|
|
862
1528
|
message: t('ws.error.projectPathNotFound'),
|
|
863
1529
|
});
|
|
864
|
-
return;
|
|
1530
|
+
return false;
|
|
865
1531
|
}
|
|
866
1532
|
// Buffer the user's message so reconnecting clients can display it
|
|
867
|
-
// (SDK may not have written the JSONL file yet at reconnect time)
|
|
868
|
-
|
|
1533
|
+
// (SDK may not have written the JSONL file yet at reconnect time).
|
|
1534
|
+
// Include timestamp for correct ordering. For images, only send count
|
|
1535
|
+
// (not full base64 data) to avoid bloating the buffer.
|
|
1536
|
+
emit('user:message', {
|
|
1537
|
+
content,
|
|
1538
|
+
sessionId: sessionId || '',
|
|
1539
|
+
timestamp: new Date().toISOString(),
|
|
1540
|
+
...(images && images.length > 0 ? { imageCount: images.length } : {}),
|
|
1541
|
+
});
|
|
869
1542
|
const isResuming = resume && sessionId;
|
|
870
1543
|
const sessionService = new SessionService();
|
|
871
1544
|
let timeoutId = null;
|
|
@@ -888,11 +1561,11 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
888
1561
|
// Create canUseTool callback for permission & AskUserQuestion handling
|
|
889
1562
|
// Promise stays pending if socket disconnected — SDK naturally waits
|
|
890
1563
|
const canUseTool = async (toolName, input, options) => {
|
|
891
|
-
// Auto-approve ExitPlanMode when the
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
if (toolName === 'ExitPlanMode' &&
|
|
895
|
-
log.debug('Auto-approving ExitPlanMode:
|
|
1564
|
+
// Auto-approve ExitPlanMode when the current permission mode is Bypass.
|
|
1565
|
+
// Use chatService.getPermissionMode() instead of the closure-captured variable
|
|
1566
|
+
// so that mid-stream permission mode changes (e.g. Plan → Bypass) are reflected.
|
|
1567
|
+
if (toolName === 'ExitPlanMode' && chatService.getPermissionMode() === 'bypassPermissions') {
|
|
1568
|
+
log.debug('Auto-approving ExitPlanMode: current permissionMode is bypassPermissions');
|
|
896
1569
|
return { behavior: 'allow', updatedInput: input };
|
|
897
1570
|
}
|
|
898
1571
|
const isAskUserQuestion = toolName === 'AskUserQuestion';
|
|
@@ -991,13 +1664,6 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
991
1664
|
}
|
|
992
1665
|
}
|
|
993
1666
|
const sdkError = parseSDKError(error, lang);
|
|
994
|
-
if (isResuming && isSessionNotFoundError(error)) {
|
|
995
|
-
emit('error', {
|
|
996
|
-
code: ERROR_CODES.SESSION_NOT_FOUND,
|
|
997
|
-
message: t('ws.error.sessionNotFound'),
|
|
998
|
-
});
|
|
999
|
-
return;
|
|
1000
|
-
}
|
|
1001
1667
|
emit('error', {
|
|
1002
1668
|
code: ERROR_CODES.CHAT_ERROR,
|
|
1003
1669
|
message: sdkError.message,
|
|
@@ -1007,33 +1673,48 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1007
1673
|
notificationService.notifyError(stream.sessionId, sdkError.message);
|
|
1008
1674
|
}
|
|
1009
1675
|
};
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1676
|
+
// Attempt to send — if resume fails with session-not-found, retry without resume
|
|
1677
|
+
try {
|
|
1678
|
+
await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
|
|
1679
|
+
resetTimeout(`raw:${messageType}`);
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
catch (sendError) {
|
|
1683
|
+
// Resume failed because session doesn't exist (e.g., first send was aborted before SDK created it).
|
|
1684
|
+
// Retry once without resume so SDK creates a fresh session.
|
|
1685
|
+
if (isResuming && sendError instanceof Error && isSessionNotFoundError(sendError)) {
|
|
1686
|
+
log.info(`[CHAIN-DRAIN] resume failed (session not found), retrying without resume: sessionId=${sessionId}`);
|
|
1687
|
+
const retryOptions = { ...chatOptions, resume: undefined, sessionId };
|
|
1688
|
+
delete retryOptions.resume;
|
|
1689
|
+
resetTimeout('resume-retry');
|
|
1690
|
+
await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
|
|
1691
|
+
resetTimeout(`raw:${messageType}`);
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
else {
|
|
1695
|
+
throw sendError;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return true;
|
|
1013
1699
|
}
|
|
1014
1700
|
catch (error) {
|
|
1015
1701
|
const sdkError = parseSDKError(error, lang);
|
|
1702
|
+
log.info(`[CHAIN-DRAIN] handleChatSend catch: sessionId=${stream.sessionId}, aborted=${abortController.signal.aborted}, reason=${abortController.signal.reason}, error=${sdkError.message.slice(0, 120)}`);
|
|
1016
1703
|
if (sdkError instanceof AbortedError || abortController.signal.aborted) {
|
|
1017
1704
|
if (abortController.signal.reason === 'user-abort' || abortController.signal.reason === 'another-client') {
|
|
1018
|
-
return;
|
|
1705
|
+
return false;
|
|
1019
1706
|
}
|
|
1020
1707
|
emit('error', {
|
|
1021
1708
|
code: ERROR_CODES.TIMEOUT_ERROR,
|
|
1022
1709
|
message: t('ws.error.timeout'),
|
|
1023
1710
|
});
|
|
1024
|
-
return;
|
|
1025
|
-
}
|
|
1026
|
-
if (isResuming && error instanceof Error && isSessionNotFoundError(error)) {
|
|
1027
|
-
emit('error', {
|
|
1028
|
-
code: ERROR_CODES.SESSION_NOT_FOUND,
|
|
1029
|
-
message: t('ws.error.sessionNotFound'),
|
|
1030
|
-
});
|
|
1031
|
-
return;
|
|
1711
|
+
return false;
|
|
1032
1712
|
}
|
|
1033
1713
|
emit('error', {
|
|
1034
1714
|
code: ERROR_CODES.CHAT_ERROR,
|
|
1035
1715
|
message: sdkError.message,
|
|
1036
1716
|
});
|
|
1717
|
+
return false;
|
|
1037
1718
|
}
|
|
1038
1719
|
finally {
|
|
1039
1720
|
if (timeoutId) {
|