hammoc 1.0.3 → 1.1.0
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-BfemoSfM.js → index-CFWfpySn.js} +1 -1
- package/packages/client/dist/assets/index-D8ezrT4P.js +1446 -0
- package/packages/client/dist/assets/index-JvaBmdnx.css +32 -0
- package/packages/client/dist/favicon-192.png +0 -0
- package/packages/client/dist/favicon-512.png +0 -0
- package/packages/client/dist/favicon.png +0 -0
- package/packages/client/dist/index.html +5 -3
- package/packages/client/dist/manifest.webmanifest +1 -1
- 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 +91 -39
- 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 +2 -1
- package/packages/server/dist/controllers/sessionController.js.map +1 -1
- package/packages/server/dist/handlers/websocket.d.ts +12 -2
- package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
- package/packages/server/dist/handlers/websocket.js +785 -106
- 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 +159 -14
- 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 +145 -156
- 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 +121 -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 +20 -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 +8 -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 +10 -2
- package/packages/shared/dist/types/history.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.d.ts +26 -1
- 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 +23 -0
- package/packages/shared/dist/types/sdk.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.js +48 -0
- package/packages/shared/dist/types/sdk.js.map +1 -1
- package/packages/shared/dist/types/streaming.d.ts +1 -0
- package/packages/shared/dist/types/streaming.d.ts.map +1 -1
- package/packages/shared/dist/types/streaming.js.map +1 -1
- package/packages/shared/dist/types/websocket.d.ts +41 -1
- 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-B335pbdL.js +0 -1297
- package/packages/client/dist/assets/index-CmTNL_3X.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,104 @@ 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 the completed buffer for a session (null if none or expired). */
|
|
408
|
+
export function getCompletedBuffer(sessionId) {
|
|
409
|
+
const completed = completedBuffers.get(sessionId);
|
|
410
|
+
return completed ? completed.events : null;
|
|
411
|
+
}
|
|
412
|
+
/** Clean up a stream from activeStreams immediately. Saves the buffer to
|
|
413
|
+
* completedBuffers for 5 seconds so it remains available independently
|
|
414
|
+
* of any new stream that may be created for the same session. */
|
|
415
|
+
function cleanupStream(streamKey, expectedStream) {
|
|
416
|
+
const current = activeStreams.get(streamKey);
|
|
417
|
+
// Identity guard: if caller specifies the expected stream but a replacement has
|
|
418
|
+
// taken over, don't delete activeStreams (would remove the new stream). However,
|
|
419
|
+
// still save the completed buffer so clients can replay the finished turn.
|
|
420
|
+
const replaced = expectedStream && current !== expectedStream;
|
|
421
|
+
const stream = expectedStream ?? current;
|
|
422
|
+
// Keep a reference to the completed buffer independently of activeStreams.
|
|
423
|
+
// No copy needed — the buffer is immutable after completion (createStreamEmit
|
|
424
|
+
// only pushes to running streams). Only the buffer array is retained; the rest
|
|
425
|
+
// of the stream object (sockets, chatService, etc.) is released for GC.
|
|
426
|
+
if (stream && stream.buffer.length > 0) {
|
|
427
|
+
// Only write if this stream is newer than (or same as) any existing entry.
|
|
428
|
+
// An older stream's delayed finalizeStream must not overwrite a newer buffer.
|
|
429
|
+
const existing = completedBuffers.get(streamKey);
|
|
430
|
+
if (!existing || stream.startedAt >= existing.startedAt) {
|
|
431
|
+
// Cancel previous expiry timer so the old buffer can be GC'd immediately
|
|
432
|
+
const prevTimer = completedBufferTimers.get(streamKey);
|
|
433
|
+
if (prevTimer)
|
|
434
|
+
clearTimeout(prevTimer);
|
|
435
|
+
completedBuffers.set(streamKey, {
|
|
436
|
+
events: stream.buffer,
|
|
437
|
+
startedAt: stream.startedAt,
|
|
438
|
+
});
|
|
439
|
+
const timer = setTimeout(() => {
|
|
440
|
+
// Guard: only delete if this timer is still the current one for this key.
|
|
441
|
+
if (completedBufferTimers.get(streamKey) === timer) {
|
|
442
|
+
completedBuffers.delete(streamKey);
|
|
443
|
+
completedBufferTimers.delete(streamKey);
|
|
444
|
+
}
|
|
445
|
+
}, COMPLETED_BUFFER_TTL_MS);
|
|
446
|
+
completedBufferTimers.set(streamKey, timer);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Only delete from activeStreams and clean up socket mappings if the stream
|
|
450
|
+
// hasn't been replaced. When replaced, the new stream owns those resources.
|
|
451
|
+
if (!replaced) {
|
|
452
|
+
activeStreams.delete(streamKey);
|
|
453
|
+
for (const [sockId, sessId] of socketToSession.entries()) {
|
|
454
|
+
if (sessId === streamKey)
|
|
455
|
+
socketToSession.delete(sockId);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** Normalize legacy 'never' sync policy to 'streaming' */
|
|
460
|
+
function normalizeSyncPolicy(policy) {
|
|
461
|
+
return policy === 'always' ? 'always' : 'streaming';
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Persist the stream's final permission mode to .hammoc/session-permissions.json.
|
|
465
|
+
* Returns a promise that resolves when persistence is complete (or fails silently).
|
|
466
|
+
* Must be called before sessionProjectMap.delete() for the given sessionId.
|
|
467
|
+
*/
|
|
468
|
+
async function persistSessionPermissionMode(sessionId, mode, fallbackSlug) {
|
|
469
|
+
const slug = sessionProjectMap.get(sessionId) || fallbackSlug;
|
|
470
|
+
if (!slug)
|
|
471
|
+
return;
|
|
472
|
+
try {
|
|
473
|
+
const projectPath = await projectService.resolveProjectPath(slug);
|
|
474
|
+
if (projectPath) {
|
|
475
|
+
await projectService.updateSessionPermission(projectPath, sessionId, mode);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
log.error('Failed to persist session permission mode:', err);
|
|
141
480
|
}
|
|
142
481
|
}
|
|
143
482
|
/**
|
|
@@ -203,27 +542,52 @@ export function rekeyStream(stream, newSessionId) {
|
|
|
203
542
|
* Mark a stream as completed and broadcast stream-change.
|
|
204
543
|
* Cleans up from activeStreams map.
|
|
205
544
|
*/
|
|
206
|
-
export function finalizeStream(sessionId) {
|
|
545
|
+
export async function finalizeStream(sessionId) {
|
|
207
546
|
const stream = activeStreams.get(sessionId);
|
|
208
547
|
if (stream) {
|
|
548
|
+
const finalMode = stream.chatService?.getPermissionMode();
|
|
549
|
+
if (finalMode)
|
|
550
|
+
await persistSessionPermissionMode(sessionId, finalMode);
|
|
209
551
|
stream.status = 'completed';
|
|
210
|
-
cleanupStream
|
|
552
|
+
// Pass stream reference so cleanupStream won't accidentally clean up a
|
|
553
|
+
// replacement stream that started during the async persistence above.
|
|
554
|
+
cleanupStream(sessionId, stream);
|
|
211
555
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
556
|
+
// Only emit inactive status and clear project mapping if a replacement stream
|
|
557
|
+
// hasn't taken over during the async persistence above. Without this guard,
|
|
558
|
+
// a new running stream would be falsely reported as inactive.
|
|
559
|
+
// Re-read slug at use time to avoid ABA race with stale capture.
|
|
560
|
+
const currentStream = activeStreams.get(sessionId);
|
|
561
|
+
if (!currentStream || currentStream.status !== 'running') {
|
|
562
|
+
const freshSlug = sessionProjectMap.get(sessionId);
|
|
563
|
+
emitStreamChange(sessionId, false, freshSlug ?? null);
|
|
564
|
+
if (freshSlug) {
|
|
565
|
+
sessionProjectMap.delete(sessionId);
|
|
566
|
+
triggerDashboardStatusChange(freshSlug);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Emit session:stream-change scoped to the project room when projectSlug is known,
|
|
572
|
+
* falling back to global broadcast otherwise.
|
|
573
|
+
*/
|
|
574
|
+
function emitStreamChange(sessionId, active, projectSlug) {
|
|
575
|
+
const payload = { sessionId, active, projectSlug };
|
|
576
|
+
if (projectSlug) {
|
|
577
|
+
io.to(`project:${projectSlug}`).emit('session:stream-change', payload);
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
io.emit('session:stream-change', payload);
|
|
217
581
|
}
|
|
218
582
|
}
|
|
219
583
|
/**
|
|
220
|
-
* Broadcast session:stream-change to all
|
|
584
|
+
* Broadcast session:stream-change to project room (or all clients as fallback).
|
|
221
585
|
* Used by queue service to signal stream start/end.
|
|
222
586
|
* Story 20.1: Also triggers dashboard status change when projectSlug is known.
|
|
223
587
|
*/
|
|
224
588
|
export function broadcastStreamChange(sessionId, active) {
|
|
225
|
-
io.emit('session:stream-change', { sessionId, active });
|
|
226
589
|
const slug = sessionProjectMap.get(sessionId);
|
|
590
|
+
emitStreamChange(sessionId, active, slug ?? null);
|
|
227
591
|
if (slug) {
|
|
228
592
|
triggerDashboardStatusChange(slug);
|
|
229
593
|
if (!active)
|
|
@@ -256,7 +620,7 @@ function matchAcceptLanguage(header) {
|
|
|
256
620
|
*/
|
|
257
621
|
export async function initializeWebSocket(httpServer) {
|
|
258
622
|
io = new SocketIOServer(httpServer, {
|
|
259
|
-
cors: config.
|
|
623
|
+
cors: config.cors,
|
|
260
624
|
maxHttpBufferSize: 100 * 1024 * 1024, // 100MB for base64 image payloads
|
|
261
625
|
});
|
|
262
626
|
// Session middleware for WebSocket (Story 2.5 - Task 4)
|
|
@@ -302,7 +666,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
302
666
|
try {
|
|
303
667
|
const clientIP = extractClientIP(socket);
|
|
304
668
|
const isLocal = isLocalIP(clientIP);
|
|
305
|
-
const terminalEnabled =
|
|
669
|
+
const terminalEnabled = preferencesService.getTerminalEnabled();
|
|
306
670
|
socket.emit('terminal:access', {
|
|
307
671
|
allowed: terminalEnabled && isLocal,
|
|
308
672
|
enabled: terminalEnabled,
|
|
@@ -387,6 +751,8 @@ export async function initializeWebSocket(httpServer) {
|
|
|
387
751
|
startedAt: Date.now(),
|
|
388
752
|
};
|
|
389
753
|
activeStreams.set(streamKey, stream);
|
|
754
|
+
// Bump drain generation so any pending scheduled drain is invalidated
|
|
755
|
+
chainDrainGeneration.set(streamKey, (chainDrainGeneration.get(streamKey) || 0) + 1);
|
|
390
756
|
for (const sock of initialSockets) {
|
|
391
757
|
socketToSession.set(sock.id, streamKey);
|
|
392
758
|
}
|
|
@@ -400,23 +766,50 @@ export async function initializeWebSocket(httpServer) {
|
|
|
400
766
|
}
|
|
401
767
|
}).catch(() => { });
|
|
402
768
|
try {
|
|
403
|
-
await handleChatSend(stream, data, abortController, lang);
|
|
769
|
+
const sendSuccess = await handleChatSend(stream, data, abortController, lang);
|
|
770
|
+
if (sendSuccess)
|
|
771
|
+
chainResumableSessions.add(stream.sessionId);
|
|
404
772
|
}
|
|
405
773
|
finally {
|
|
406
774
|
stream.status = 'completed';
|
|
407
775
|
const endedSessionId = stream.sessionId;
|
|
776
|
+
const isCurrentStream = activeStreams.get(endedSessionId) === stream;
|
|
777
|
+
log.info(`[CHAIN-DRAIN] chat:send finally: endedSessionId=${endedSessionId}, isCurrentStream=${isCurrentStream}, socketsOnStream=${stream.sockets.size}`);
|
|
408
778
|
// Only cleanup if this stream is still the active one for this session.
|
|
409
779
|
// A replacement stream (from another chat:send) may have already taken over
|
|
410
780
|
// the same key — deleting it would be a race condition.
|
|
411
|
-
if (
|
|
781
|
+
if (isCurrentStream) {
|
|
782
|
+
const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
|
|
412
783
|
cleanupStream(endedSessionId);
|
|
413
|
-
|
|
784
|
+
emitStreamChange(endedSessionId, false, sendEndSlug);
|
|
785
|
+
// Persist per-session permission mode before cleanup
|
|
786
|
+
const sendFinalMode = stream.chatService?.getPermissionMode();
|
|
787
|
+
if (sendFinalMode)
|
|
788
|
+
await persistSessionPermissionMode(endedSessionId, sendFinalMode);
|
|
414
789
|
// Story 20.1: Trigger dashboard status change on stream end
|
|
415
790
|
const endProjectSlug = sessionProjectMap.get(endedSessionId);
|
|
416
791
|
if (endProjectSlug) {
|
|
792
|
+
// Update sessions-index.json so future list queries hit cache
|
|
793
|
+
new SessionService().updateSessionIndex(endProjectSlug, endedSessionId).catch((err) => {
|
|
794
|
+
log.warn(`Failed to update session index: project=${endProjectSlug} session=${endedSessionId}`, err);
|
|
795
|
+
});
|
|
417
796
|
triggerDashboardStatusChange(endProjectSlug);
|
|
418
797
|
sessionProjectMap.delete(endedSessionId);
|
|
419
798
|
}
|
|
799
|
+
// Story 24.1: Schedule chain drain if pending items exist (browser-independent)
|
|
800
|
+
const pendingChain = chainState.get(endedSessionId);
|
|
801
|
+
const pendingCount = pendingChain?.filter(item => item.status === 'pending').length ?? 0;
|
|
802
|
+
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 })) ?? [])}`);
|
|
803
|
+
if (pendingChain && pendingChain.some(item => item.status === 'pending')) {
|
|
804
|
+
scheduleChainDrain(endedSessionId, lang);
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
log.info(`[CHAIN-DRAIN] chat:send finally: no pending chain items, calling cleanupChainIfIdle for ${endedSessionId}`);
|
|
808
|
+
cleanupChainIfIdle(endedSessionId);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
log.warn(`[CHAIN-DRAIN] chat:send finally: stream is NOT current active stream (replaced?): endedSessionId=${endedSessionId}`);
|
|
420
813
|
}
|
|
421
814
|
}
|
|
422
815
|
});
|
|
@@ -430,17 +823,16 @@ export async function initializeWebSocket(httpServer) {
|
|
|
430
823
|
stream.pendingPermissions.get(data.requestId).resolve({ approved: data.approved, response: data.response });
|
|
431
824
|
stream.pendingPermissions.delete(data.requestId);
|
|
432
825
|
// 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
|
-
}
|
|
826
|
+
// tool/interactive cards can show the correct approve/deny state.
|
|
827
|
+
// Also buffer via createStreamEmit so reconnecting clients see the
|
|
828
|
+
// resolved state instead of a stale 'waiting' permission card.
|
|
829
|
+
const emit = createStreamEmit(stream);
|
|
830
|
+
emit('permission:resolved', {
|
|
831
|
+
requestId: data.requestId,
|
|
832
|
+
approved: data.approved,
|
|
833
|
+
interactionType: data.interactionType,
|
|
834
|
+
response: data.response,
|
|
835
|
+
});
|
|
444
836
|
}
|
|
445
837
|
else {
|
|
446
838
|
// Permission already resolved by another viewer — notify sender
|
|
@@ -462,32 +854,78 @@ export async function initializeWebSocket(httpServer) {
|
|
|
462
854
|
}
|
|
463
855
|
stream.abortController.abort('user-abort');
|
|
464
856
|
}
|
|
857
|
+
// Also clear any pending prompt chain — user expects everything to stop.
|
|
858
|
+
// Always bump generation to invalidate any in-flight drain timers,
|
|
859
|
+
// even if chainState is already empty (stale timer edge case).
|
|
860
|
+
const gen = (chainDrainGeneration.get(sessionId) || 0) + 1;
|
|
861
|
+
chainDrainGeneration.set(sessionId, gen);
|
|
862
|
+
const chainItems = chainState.get(sessionId);
|
|
863
|
+
if (chainItems && chainItems.length > 0) {
|
|
864
|
+
chainState.set(sessionId, []);
|
|
865
|
+
broadcastChainUpdate(sessionId);
|
|
866
|
+
// Do NOT call cleanupChainIfIdle here — the active stream still exists
|
|
867
|
+
// and scheduleChainDrain's finally block will handle cleanup after it completes.
|
|
868
|
+
clearPersistedFailures(sessionId)
|
|
869
|
+
.then(() => {
|
|
870
|
+
// Guard: only broadcast if no new chain was started since this abort
|
|
871
|
+
if (chainDrainGeneration.get(sessionId) === gen) {
|
|
872
|
+
broadcastChainUpdate(sessionId);
|
|
873
|
+
}
|
|
874
|
+
})
|
|
875
|
+
.catch((err) => {
|
|
876
|
+
log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
465
879
|
});
|
|
466
|
-
// Handle permission:mode-change — update SDK permission mode
|
|
880
|
+
// Handle permission:mode-change — update SDK permission mode and broadcast to viewers
|
|
467
881
|
socket.on('permission:mode-change', async (data) => {
|
|
468
|
-
const sessionId = socketToSession.get(socket.id);
|
|
882
|
+
const sessionId = socketToSession.get(socket.id) || socketSessionRoom.get(socket.id);
|
|
469
883
|
if (!sessionId)
|
|
470
884
|
return;
|
|
885
|
+
const { mode, projectSlug } = data;
|
|
471
886
|
const stream = activeStreams.get(sessionId);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
887
|
+
// 1) Update SDK permission mode — only when stream is actively running
|
|
888
|
+
if (stream?.chatService && stream.status === 'running') {
|
|
889
|
+
try {
|
|
890
|
+
await stream.chatService.setPermissionMode(mode);
|
|
891
|
+
log.debug(`Permission mode changed to "${mode}" for session ${sessionId}`);
|
|
892
|
+
}
|
|
893
|
+
catch (err) {
|
|
894
|
+
log.error('Failed to change permission mode:', err);
|
|
895
|
+
return; // Don't persist or broadcast a mode that failed to apply
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
// Update pending chain items so the next drain uses the new mode.
|
|
899
|
+
// Outside the running-stream block because mode can change between turns
|
|
900
|
+
// (e.g., during the 1s chain drain delay when no stream is active).
|
|
901
|
+
const chainItems = chainState.get(sessionId);
|
|
902
|
+
if (chainItems) {
|
|
903
|
+
for (const item of chainItems) {
|
|
904
|
+
if (item.status === 'pending' || item.status === 'sending') {
|
|
905
|
+
item.permissionMode = mode;
|
|
481
906
|
}
|
|
482
907
|
}
|
|
483
908
|
}
|
|
909
|
+
// 2) Always persist per-session permission mode (read only when policy is 'always')
|
|
910
|
+
// Use projectSlug from client as fallback when sessionProjectMap entry is gone (stream ended)
|
|
911
|
+
await persistSessionPermissionMode(sessionId, mode, projectSlug);
|
|
912
|
+
// 3) Broadcast to other viewers based on sync policy
|
|
913
|
+
let syncPolicy = 'streaming';
|
|
914
|
+
try {
|
|
915
|
+
const prefs = await preferencesService.readPreferences();
|
|
916
|
+
syncPolicy = normalizeSyncPolicy(prefs.permissionSyncPolicy);
|
|
917
|
+
}
|
|
484
918
|
catch (err) {
|
|
485
|
-
log.error('Failed to
|
|
919
|
+
log.error('Failed to read preferences for sync policy:', err);
|
|
486
920
|
}
|
|
921
|
+
if (syncPolicy === 'streaming' && stream?.status !== 'running')
|
|
922
|
+
return;
|
|
923
|
+
// 'always' or ('streaming' + running) → broadcast via Socket.io room
|
|
924
|
+
socket.to(`session:${sessionId}`).emit('permission:mode-change', { mode });
|
|
487
925
|
});
|
|
488
926
|
// Handle session:join event — attach socket to active running stream (broadcast)
|
|
489
927
|
// Also joins a persistent Socket.io room so future streams auto-include this socket.
|
|
490
|
-
socket.on('session:join', (sessionId) => {
|
|
928
|
+
socket.on('session:join', (sessionId, projectSlug) => {
|
|
491
929
|
// Detach this socket from any previously-attached stream to prevent
|
|
492
930
|
// events from the old stream leaking to a different session's listeners
|
|
493
931
|
const prevSessionId = socketToSession.get(socket.id);
|
|
@@ -499,22 +937,124 @@ export async function initializeWebSocket(httpServer) {
|
|
|
499
937
|
socketToSession.delete(socket.id);
|
|
500
938
|
socket.leave(`session:${prevSessionId}`);
|
|
501
939
|
}
|
|
940
|
+
// Also leave previous session room even when no active stream existed
|
|
941
|
+
// (socketToSession is only set when a stream is running)
|
|
942
|
+
const prevRoomSessionId = socketSessionRoom.get(socket.id);
|
|
943
|
+
if (prevRoomSessionId && prevRoomSessionId !== sessionId && prevRoomSessionId !== prevSessionId) {
|
|
944
|
+
socket.leave(`session:${prevRoomSessionId}`);
|
|
945
|
+
}
|
|
502
946
|
// Join persistent session room (survives beyond ActiveStream lifecycle)
|
|
503
947
|
socket.join(`session:${sessionId}`);
|
|
948
|
+
// Leave previous project room if switching projects (or if new join has no projectSlug)
|
|
949
|
+
const prevProjectRoom = socketProjectRoom.get(socket.id);
|
|
950
|
+
if (prevProjectRoom && prevProjectRoom !== projectSlug) {
|
|
951
|
+
socket.leave(`project:${prevProjectRoom}`);
|
|
952
|
+
socketProjectRoom.delete(socket.id);
|
|
953
|
+
}
|
|
954
|
+
// Join project room so scoped events (e.g., session:stream-change) are received
|
|
955
|
+
if (projectSlug) {
|
|
956
|
+
socket.join(`project:${projectSlug}`);
|
|
957
|
+
socketProjectRoom.set(socket.id, projectSlug);
|
|
958
|
+
}
|
|
959
|
+
// Story 24.3: Track session room membership for session:leave room management
|
|
960
|
+
socketSessionRoom.set(socket.id, sessionId);
|
|
504
961
|
const stream = activeStreams.get(sessionId);
|
|
962
|
+
// Story 24.1: Send current chain state on join (in-memory + persisted failures)
|
|
963
|
+
// Only attempt disk read for valid UUID sessionIds to avoid unbounded lock map growth
|
|
964
|
+
if (UUID_RE.test(sessionId)) {
|
|
965
|
+
withChainFailureLock(sessionId, async () => {
|
|
966
|
+
return projectService.readChainFailures(sessionId);
|
|
967
|
+
}).then(failures => {
|
|
968
|
+
// Re-read in-memory state now (may have changed during async disk read)
|
|
969
|
+
const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
|
|
970
|
+
const allItems = [...freshItems, ...failures];
|
|
971
|
+
socket.emit('chain:update', { sessionId, items: allItems });
|
|
972
|
+
}).catch((err) => {
|
|
973
|
+
log.error(`Failed to read chain failures on join (session ${sessionId}):`, err);
|
|
974
|
+
const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
|
|
975
|
+
socket.emit('chain:update', { sessionId, items: freshItems });
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
else {
|
|
979
|
+
// Non-UUID session: only send in-memory chain state (no disk persistence)
|
|
980
|
+
const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
|
|
981
|
+
socket.emit('chain:update', { sessionId, items: freshItems });
|
|
982
|
+
}
|
|
505
983
|
if (!stream || stream.status !== 'running') {
|
|
506
|
-
|
|
984
|
+
// Emit inactive status + completed buffer replay.
|
|
985
|
+
// Wrapped in a helper that re-checks activeStreams because the async
|
|
986
|
+
// preference-read path can yield, and a new stream may start in between.
|
|
987
|
+
const emitInactiveWithReplay = (permissionMode) => {
|
|
988
|
+
// Stale callback guard: if the socket has left this session (moved to
|
|
989
|
+
// another session or disconnected), don't emit anything for the old session.
|
|
990
|
+
if (socketSessionRoom.get(socket.id) !== sessionId || !socket.connected) {
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
// Re-check: if a running stream appeared during async wait, emit active
|
|
994
|
+
// state instead of stale inactive. Without this, the client would miss
|
|
995
|
+
// the initial stream:status/buffer-replay for the new stream.
|
|
996
|
+
const freshStream = activeStreams.get(sessionId);
|
|
997
|
+
if (freshStream && freshStream.status === 'running') {
|
|
998
|
+
socketToSession.set(socket.id, sessionId);
|
|
999
|
+
const bufSnapshot = [...freshStream.buffer];
|
|
1000
|
+
const freshMode = freshStream.chatService?.getPermissionMode();
|
|
1001
|
+
socket.emit('stream:status', { active: true, sessionId, permissionMode: freshMode });
|
|
1002
|
+
const completedBuf = getCompletedBuffer(sessionId);
|
|
1003
|
+
if (completedBuf) {
|
|
1004
|
+
socket.emit('stream:buffer-replay', { sessionId, events: completedBuf });
|
|
1005
|
+
}
|
|
1006
|
+
socket.emit('stream:buffer-replay', { sessionId, events: bufSnapshot });
|
|
1007
|
+
freshStream.sockets.add(socket);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
socket.emit('stream:status', { active: false, sessionId, permissionMode });
|
|
1011
|
+
// Replay recently completed stream buffer so the client has the finished turn
|
|
1012
|
+
const completed = getCompletedBuffer(sessionId);
|
|
1013
|
+
if (completed) {
|
|
1014
|
+
socket.emit('stream:buffer-replay', { sessionId, events: completed });
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
// For 'always' sync policy, restore per-session permission mode from disk
|
|
1018
|
+
const resolvedSlug = projectSlug || sessionProjectMap.get(sessionId);
|
|
1019
|
+
if (resolvedSlug && UUID_RE.test(sessionId)) {
|
|
1020
|
+
preferencesService.readPreferences().then(async (prefs) => {
|
|
1021
|
+
if (normalizeSyncPolicy(prefs.permissionSyncPolicy) === 'always') {
|
|
1022
|
+
const projectPath = await projectService.resolveProjectPath(resolvedSlug);
|
|
1023
|
+
if (projectPath) {
|
|
1024
|
+
const perms = await projectService.readSessionPermissions(projectPath);
|
|
1025
|
+
const savedMode = perms[sessionId];
|
|
1026
|
+
emitInactiveWithReplay(savedMode);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
emitInactiveWithReplay();
|
|
1031
|
+
}).catch(() => {
|
|
1032
|
+
emitInactiveWithReplay();
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
emitInactiveWithReplay();
|
|
1037
|
+
}
|
|
507
1038
|
return;
|
|
508
1039
|
}
|
|
509
|
-
// Add socket to broadcast set (multiple browsers can watch simultaneously)
|
|
510
|
-
stream.sockets.add(socket);
|
|
511
1040
|
socketToSession.set(socket.id, sessionId);
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
1041
|
+
// Snapshot BEFORE adding socket to broadcast set to prevent race.
|
|
1042
|
+
const bufferSnapshot = [...stream.buffer];
|
|
1043
|
+
const permissionMode = stream.chatService?.getPermissionMode();
|
|
1044
|
+
// Emit order matters for the client:
|
|
1045
|
+
// 1. stream:status { active: true } → client calls restoreStreaming + trimMessagesAfterLastUser
|
|
1046
|
+
// 2. completed buffer replay → client calls addMessages (trim already ran, won't remove these)
|
|
1047
|
+
// 3. active buffer replay → client sets streaming segments for the current turn
|
|
1048
|
+
socket.emit('stream:status', { active: true, sessionId, permissionMode });
|
|
1049
|
+
// Replay recently completed stream buffer (e.g., previous chain turn) AFTER
|
|
1050
|
+
// stream:status so the client has already trimmed stale messages.
|
|
1051
|
+
const completedBuf = getCompletedBuffer(sessionId);
|
|
1052
|
+
if (completedBuf) {
|
|
1053
|
+
socket.emit('stream:buffer-replay', { sessionId, events: completedBuf });
|
|
517
1054
|
}
|
|
1055
|
+
socket.emit('stream:buffer-replay', { sessionId, events: bufferSnapshot });
|
|
1056
|
+
// NOW add to broadcast set — live events flow from here
|
|
1057
|
+
stream.sockets.add(socket);
|
|
518
1058
|
});
|
|
519
1059
|
// Handle session:leave event — detach socket from current stream and session room
|
|
520
1060
|
// (client navigating away from a session while streaming continues in background)
|
|
@@ -527,12 +1067,133 @@ export async function initializeWebSocket(httpServer) {
|
|
|
527
1067
|
}
|
|
528
1068
|
socketToSession.delete(socket.id);
|
|
529
1069
|
}
|
|
530
|
-
// Use prevSessionId as fallback — client may send empty
|
|
531
|
-
// the sessionId is not available at unmount time (e.g., ChatPage cleanup)
|
|
532
|
-
const roomSessionId = sessionId || prevSessionId;
|
|
1070
|
+
// Use prevSessionId / socketSessionRoom as fallback — client may send empty
|
|
1071
|
+
// string when the sessionId is not available at unmount time (e.g., ChatPage cleanup)
|
|
1072
|
+
const roomSessionId = sessionId || prevSessionId || socketSessionRoom.get(socket.id);
|
|
533
1073
|
if (roomSessionId) {
|
|
534
1074
|
socket.leave(`session:${roomSessionId}`);
|
|
535
1075
|
}
|
|
1076
|
+
// Story 24.3: Clean up session room tracking on leave
|
|
1077
|
+
// Only clear project room if the leaving session matches current tracking.
|
|
1078
|
+
// Prevents race where a new session:join overwrites tracking before old leave arrives.
|
|
1079
|
+
const trackedSession = socketSessionRoom.get(socket.id);
|
|
1080
|
+
if (!trackedSession || trackedSession === roomSessionId) {
|
|
1081
|
+
socketSessionRoom.delete(socket.id);
|
|
1082
|
+
const prevProjectSlug = socketProjectRoom.get(socket.id);
|
|
1083
|
+
if (prevProjectSlug) {
|
|
1084
|
+
socket.leave(`project:${prevProjectSlug}`);
|
|
1085
|
+
}
|
|
1086
|
+
socketProjectRoom.delete(socket.id);
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
// Story 24.1: Prompt chain event handlers
|
|
1090
|
+
socket.on('chain:add', (data) => {
|
|
1091
|
+
if (!data || typeof data !== 'object')
|
|
1092
|
+
return;
|
|
1093
|
+
const { sessionId, content, workingDirectory, permissionMode, model } = data;
|
|
1094
|
+
const lang = socket.data.language || 'en';
|
|
1095
|
+
const t = i18next.getFixedT(lang);
|
|
1096
|
+
// Input validation (UUID required for disk persistence compatibility)
|
|
1097
|
+
if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
|
|
1098
|
+
return;
|
|
1099
|
+
if (!content || typeof content !== 'string' || !content.trim())
|
|
1100
|
+
return;
|
|
1101
|
+
if (!workingDirectory || typeof workingDirectory !== 'string')
|
|
1102
|
+
return;
|
|
1103
|
+
if (content.length > 100_000) {
|
|
1104
|
+
socket.emit('error', { code: ERROR_CODES.CHAT_ERROR, message: t('ws.error.chainContentTooLong') });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
// Validate socket is a member of the session room
|
|
1108
|
+
if (!socket.rooms.has(`session:${sessionId}`))
|
|
1109
|
+
return;
|
|
1110
|
+
const items = chainState.get(sessionId) || [];
|
|
1111
|
+
if (items.length >= 10) {
|
|
1112
|
+
socket.emit('error', {
|
|
1113
|
+
code: ERROR_CODES.CHAIN_MAX_EXCEEDED,
|
|
1114
|
+
message: t('ws.error.chainMaxExceeded'),
|
|
1115
|
+
});
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const item = {
|
|
1119
|
+
id: generateChainItemId(),
|
|
1120
|
+
content,
|
|
1121
|
+
status: 'pending',
|
|
1122
|
+
createdAt: Date.now(),
|
|
1123
|
+
workingDirectory,
|
|
1124
|
+
permissionMode,
|
|
1125
|
+
model,
|
|
1126
|
+
};
|
|
1127
|
+
items.push(item);
|
|
1128
|
+
chainState.set(sessionId, items);
|
|
1129
|
+
broadcastChainUpdate(sessionId);
|
|
1130
|
+
// If no active stream, trigger drain so items don't stay pending indefinitely
|
|
1131
|
+
if (!activeStreams.has(sessionId)) {
|
|
1132
|
+
scheduleChainDrain(sessionId, lang);
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
socket.on('chain:remove', (data) => {
|
|
1136
|
+
if (!data || typeof data !== 'object')
|
|
1137
|
+
return;
|
|
1138
|
+
const { sessionId, id } = data;
|
|
1139
|
+
if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
|
|
1140
|
+
return;
|
|
1141
|
+
if (!id || typeof id !== 'string')
|
|
1142
|
+
return;
|
|
1143
|
+
if (!socket.rooms.has(`session:${sessionId}`))
|
|
1144
|
+
return;
|
|
1145
|
+
const items = chainState.get(sessionId);
|
|
1146
|
+
if (items) {
|
|
1147
|
+
// If the removed item is currently sending, abort its active stream
|
|
1148
|
+
const removedItem = items.find(item => item.id === id);
|
|
1149
|
+
if (removedItem?.status === 'sending') {
|
|
1150
|
+
const stream = activeStreams.get(sessionId);
|
|
1151
|
+
if (stream && stream.status === 'running') {
|
|
1152
|
+
stream.abortController.abort('chain-item-removed');
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
const filtered = items.filter(item => item.id !== id);
|
|
1156
|
+
chainState.set(sessionId, filtered);
|
|
1157
|
+
broadcastChainUpdate(sessionId);
|
|
1158
|
+
if (filtered.length === 0) {
|
|
1159
|
+
cleanupChainIfIdle(sessionId);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
// Also remove from persisted failures (dismiss)
|
|
1163
|
+
removePersistedFailure(sessionId, id).catch((err) => {
|
|
1164
|
+
log.error(`Failed to remove persisted failure ${id} for session ${sessionId}:`, err);
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1167
|
+
socket.on('chain:clear', (data) => {
|
|
1168
|
+
if (!data || typeof data !== 'object')
|
|
1169
|
+
return;
|
|
1170
|
+
const { sessionId } = data;
|
|
1171
|
+
if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
|
|
1172
|
+
return;
|
|
1173
|
+
// Validate socket is a member of the session room
|
|
1174
|
+
if (!socket.rooms.has(`session:${sessionId}`))
|
|
1175
|
+
return;
|
|
1176
|
+
// If any item is currently sending, abort its active stream
|
|
1177
|
+
const items = chainState.get(sessionId);
|
|
1178
|
+
if (items?.some(item => item.status === 'sending')) {
|
|
1179
|
+
const stream = activeStreams.get(sessionId);
|
|
1180
|
+
if (stream && stream.status === 'running') {
|
|
1181
|
+
stream.abortController.abort('chain-cleared');
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
chainState.set(sessionId, []);
|
|
1185
|
+
// Bump generation instead of deleting — prevents stale timers from matching
|
|
1186
|
+
// a reset counter value after clear + re-add sequence
|
|
1187
|
+
chainDrainGeneration.set(sessionId, (chainDrainGeneration.get(sessionId) || 0) + 1);
|
|
1188
|
+
broadcastChainUpdate(sessionId);
|
|
1189
|
+
cleanupChainIfIdle(sessionId);
|
|
1190
|
+
// Clear persisted failures from disk, then broadcast final consistent state
|
|
1191
|
+
// (prevents stale failures from reappearing via concurrent broadcastChainUpdateWithFailures)
|
|
1192
|
+
clearPersistedFailures(sessionId)
|
|
1193
|
+
.then(() => broadcastChainUpdate(sessionId))
|
|
1194
|
+
.catch((err) => {
|
|
1195
|
+
log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
|
|
1196
|
+
});
|
|
536
1197
|
});
|
|
537
1198
|
// Story 20.1: Dashboard subscribe/unsubscribe
|
|
538
1199
|
socket.on('dashboard:subscribe', () => {
|
|
@@ -607,7 +1268,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
607
1268
|
const lang = socket.data.language || 'en';
|
|
608
1269
|
const t = i18next.getFixedT(lang);
|
|
609
1270
|
// Story 17.5: Security guard
|
|
610
|
-
const access =
|
|
1271
|
+
const access = checkTerminalAccess(socket, lang);
|
|
611
1272
|
if (!access.allowed) {
|
|
612
1273
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:create`);
|
|
613
1274
|
socket.emit('terminal:error', access.error);
|
|
@@ -670,7 +1331,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
670
1331
|
});
|
|
671
1332
|
socket.on('terminal:input', async (data) => {
|
|
672
1333
|
// Story 17.5: Security guard
|
|
673
|
-
const inputAccess =
|
|
1334
|
+
const inputAccess = checkTerminalAccess(socket, socket.data.language || 'en');
|
|
674
1335
|
if (!inputAccess.allowed) {
|
|
675
1336
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:input`);
|
|
676
1337
|
socket.emit('terminal:error', { ...inputAccess.error, terminalId: data.terminalId });
|
|
@@ -686,7 +1347,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
686
1347
|
});
|
|
687
1348
|
socket.on('terminal:resize', async (data) => {
|
|
688
1349
|
// Story 17.5: Security guard
|
|
689
|
-
const resizeAccess =
|
|
1350
|
+
const resizeAccess = checkTerminalAccess(socket, socket.data.language || 'en');
|
|
690
1351
|
if (!resizeAccess.allowed) {
|
|
691
1352
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:resize`);
|
|
692
1353
|
socket.emit('terminal:error', { ...resizeAccess.error, terminalId: data.terminalId });
|
|
@@ -702,7 +1363,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
702
1363
|
});
|
|
703
1364
|
socket.on('terminal:list', async (data) => {
|
|
704
1365
|
const lang = socket.data.language || 'en';
|
|
705
|
-
const access =
|
|
1366
|
+
const access = checkTerminalAccess(socket, lang);
|
|
706
1367
|
if (!access.allowed) {
|
|
707
1368
|
socket.emit('terminal:list', { projectSlug: data.projectSlug, terminals: [] });
|
|
708
1369
|
return;
|
|
@@ -735,7 +1396,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
735
1396
|
});
|
|
736
1397
|
socket.on('terminal:close', async (data) => {
|
|
737
1398
|
// Story 17.5: Security guard
|
|
738
|
-
const closeAccess =
|
|
1399
|
+
const closeAccess = checkTerminalAccess(socket, socket.data.language || 'en');
|
|
739
1400
|
if (!closeAccess.allowed) {
|
|
740
1401
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:close`);
|
|
741
1402
|
socket.emit('terminal:error', { ...closeAccess.error, terminalId: data.terminalId });
|
|
@@ -765,6 +1426,8 @@ export async function initializeWebSocket(httpServer) {
|
|
|
765
1426
|
}
|
|
766
1427
|
socketToSession.delete(socket.id);
|
|
767
1428
|
}
|
|
1429
|
+
socketSessionRoom.delete(socket.id);
|
|
1430
|
+
socketProjectRoom.delete(socket.id);
|
|
768
1431
|
// PTY sessions are NOT cleaned up on socket disconnect.
|
|
769
1432
|
// They persist until explicitly closed by the user, the PTY process exits,
|
|
770
1433
|
// or the server shuts down. This prevents losing long-running terminal
|
|
@@ -830,7 +1493,8 @@ function isSessionNotFoundError(error) {
|
|
|
830
1493
|
return (message.includes('session not found') ||
|
|
831
1494
|
message.includes('session does not exist') ||
|
|
832
1495
|
message.includes('invalid session') ||
|
|
833
|
-
message.includes('no such session')
|
|
1496
|
+
message.includes('no such session') ||
|
|
1497
|
+
message.includes('no conversation found'));
|
|
834
1498
|
}
|
|
835
1499
|
/**
|
|
836
1500
|
* Handle chat:send event from client
|
|
@@ -849,7 +1513,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
849
1513
|
code: ERROR_CODES.VALIDATION_ERROR,
|
|
850
1514
|
message: validation.error,
|
|
851
1515
|
});
|
|
852
|
-
return;
|
|
1516
|
+
return false;
|
|
853
1517
|
}
|
|
854
1518
|
}
|
|
855
1519
|
// Validate workingDirectory exists
|
|
@@ -858,11 +1522,18 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
858
1522
|
code: ERROR_CODES.INVALID_WORKING_DIR,
|
|
859
1523
|
message: t('ws.error.projectPathNotFound'),
|
|
860
1524
|
});
|
|
861
|
-
return;
|
|
1525
|
+
return false;
|
|
862
1526
|
}
|
|
863
1527
|
// Buffer the user's message so reconnecting clients can display it
|
|
864
|
-
// (SDK may not have written the JSONL file yet at reconnect time)
|
|
865
|
-
|
|
1528
|
+
// (SDK may not have written the JSONL file yet at reconnect time).
|
|
1529
|
+
// Include timestamp for correct ordering. For images, only send count
|
|
1530
|
+
// (not full base64 data) to avoid bloating the buffer.
|
|
1531
|
+
emit('user:message', {
|
|
1532
|
+
content,
|
|
1533
|
+
sessionId: sessionId || '',
|
|
1534
|
+
timestamp: new Date().toISOString(),
|
|
1535
|
+
...(images && images.length > 0 ? { imageCount: images.length } : {}),
|
|
1536
|
+
});
|
|
866
1537
|
const isResuming = resume && sessionId;
|
|
867
1538
|
const sessionService = new SessionService();
|
|
868
1539
|
let timeoutId = null;
|
|
@@ -885,11 +1556,11 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
885
1556
|
// Create canUseTool callback for permission & AskUserQuestion handling
|
|
886
1557
|
// Promise stays pending if socket disconnected — SDK naturally waits
|
|
887
1558
|
const canUseTool = async (toolName, input, options) => {
|
|
888
|
-
// Auto-approve ExitPlanMode when the
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
if (toolName === 'ExitPlanMode' &&
|
|
892
|
-
log.debug('Auto-approving ExitPlanMode:
|
|
1559
|
+
// Auto-approve ExitPlanMode when the current permission mode is Bypass.
|
|
1560
|
+
// Use chatService.getPermissionMode() instead of the closure-captured variable
|
|
1561
|
+
// so that mid-stream permission mode changes (e.g. Plan → Bypass) are reflected.
|
|
1562
|
+
if (toolName === 'ExitPlanMode' && chatService.getPermissionMode() === 'bypassPermissions') {
|
|
1563
|
+
log.debug('Auto-approving ExitPlanMode: current permissionMode is bypassPermissions');
|
|
893
1564
|
return { behavior: 'allow', updatedInput: input };
|
|
894
1565
|
}
|
|
895
1566
|
const isAskUserQuestion = toolName === 'AskUserQuestion';
|
|
@@ -988,13 +1659,6 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
988
1659
|
}
|
|
989
1660
|
}
|
|
990
1661
|
const sdkError = parseSDKError(error, lang);
|
|
991
|
-
if (isResuming && isSessionNotFoundError(error)) {
|
|
992
|
-
emit('error', {
|
|
993
|
-
code: ERROR_CODES.SESSION_NOT_FOUND,
|
|
994
|
-
message: t('ws.error.sessionNotFound'),
|
|
995
|
-
});
|
|
996
|
-
return;
|
|
997
|
-
}
|
|
998
1662
|
emit('error', {
|
|
999
1663
|
code: ERROR_CODES.CHAT_ERROR,
|
|
1000
1664
|
message: sdkError.message,
|
|
@@ -1004,33 +1668,48 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1004
1668
|
notificationService.notifyError(stream.sessionId, sdkError.message);
|
|
1005
1669
|
}
|
|
1006
1670
|
};
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1671
|
+
// Attempt to send — if resume fails with session-not-found, retry without resume
|
|
1672
|
+
try {
|
|
1673
|
+
await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
|
|
1674
|
+
resetTimeout(`raw:${messageType}`);
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
catch (sendError) {
|
|
1678
|
+
// Resume failed because session doesn't exist (e.g., first send was aborted before SDK created it).
|
|
1679
|
+
// Retry once without resume so SDK creates a fresh session.
|
|
1680
|
+
if (isResuming && sendError instanceof Error && isSessionNotFoundError(sendError)) {
|
|
1681
|
+
log.info(`[CHAIN-DRAIN] resume failed (session not found), retrying without resume: sessionId=${sessionId}`);
|
|
1682
|
+
const retryOptions = { ...chatOptions, resume: undefined, sessionId };
|
|
1683
|
+
delete retryOptions.resume;
|
|
1684
|
+
resetTimeout('resume-retry');
|
|
1685
|
+
await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
|
|
1686
|
+
resetTimeout(`raw:${messageType}`);
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
else {
|
|
1690
|
+
throw sendError;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
return true;
|
|
1010
1694
|
}
|
|
1011
1695
|
catch (error) {
|
|
1012
1696
|
const sdkError = parseSDKError(error, lang);
|
|
1697
|
+
log.info(`[CHAIN-DRAIN] handleChatSend catch: sessionId=${stream.sessionId}, aborted=${abortController.signal.aborted}, reason=${abortController.signal.reason}, error=${sdkError.message.slice(0, 120)}`);
|
|
1013
1698
|
if (sdkError instanceof AbortedError || abortController.signal.aborted) {
|
|
1014
1699
|
if (abortController.signal.reason === 'user-abort' || abortController.signal.reason === 'another-client') {
|
|
1015
|
-
return;
|
|
1700
|
+
return false;
|
|
1016
1701
|
}
|
|
1017
1702
|
emit('error', {
|
|
1018
1703
|
code: ERROR_CODES.TIMEOUT_ERROR,
|
|
1019
1704
|
message: t('ws.error.timeout'),
|
|
1020
1705
|
});
|
|
1021
|
-
return;
|
|
1022
|
-
}
|
|
1023
|
-
if (isResuming && error instanceof Error && isSessionNotFoundError(error)) {
|
|
1024
|
-
emit('error', {
|
|
1025
|
-
code: ERROR_CODES.SESSION_NOT_FOUND,
|
|
1026
|
-
message: t('ws.error.sessionNotFound'),
|
|
1027
|
-
});
|
|
1028
|
-
return;
|
|
1706
|
+
return false;
|
|
1029
1707
|
}
|
|
1030
1708
|
emit('error', {
|
|
1031
1709
|
code: ERROR_CODES.CHAT_ERROR,
|
|
1032
1710
|
message: sdkError.message,
|
|
1033
1711
|
});
|
|
1712
|
+
return false;
|
|
1034
1713
|
}
|
|
1035
1714
|
finally {
|
|
1036
1715
|
if (timeoutId) {
|