hammoc 1.0.4 → 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-Zkw0a1l9.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/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 +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 +775 -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 +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 +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 +3 -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,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,13 +854,35 @@ 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
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;
|
|
471
|
-
const { mode,
|
|
885
|
+
const { mode, projectSlug } = data;
|
|
472
886
|
const stream = activeStreams.get(sessionId);
|
|
473
887
|
// 1) Update SDK permission mode — only when stream is actively running
|
|
474
888
|
if (stream?.chatService && stream.status === 'running') {
|
|
@@ -478,11 +892,32 @@ export async function initializeWebSocket(httpServer) {
|
|
|
478
892
|
}
|
|
479
893
|
catch (err) {
|
|
480
894
|
log.error('Failed to change permission mode:', err);
|
|
895
|
+
return; // Don't persist or broadcast a mode that failed to apply
|
|
481
896
|
}
|
|
482
897
|
}
|
|
483
|
-
//
|
|
484
|
-
|
|
485
|
-
|
|
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;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
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
|
+
}
|
|
918
|
+
catch (err) {
|
|
919
|
+
log.error('Failed to read preferences for sync policy:', err);
|
|
920
|
+
}
|
|
486
921
|
if (syncPolicy === 'streaming' && stream?.status !== 'running')
|
|
487
922
|
return;
|
|
488
923
|
// 'always' or ('streaming' + running) → broadcast via Socket.io room
|
|
@@ -490,7 +925,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
490
925
|
});
|
|
491
926
|
// Handle session:join event — attach socket to active running stream (broadcast)
|
|
492
927
|
// Also joins a persistent Socket.io room so future streams auto-include this socket.
|
|
493
|
-
socket.on('session:join', (sessionId) => {
|
|
928
|
+
socket.on('session:join', (sessionId, projectSlug) => {
|
|
494
929
|
// Detach this socket from any previously-attached stream to prevent
|
|
495
930
|
// events from the old stream leaking to a different session's listeners
|
|
496
931
|
const prevSessionId = socketToSession.get(socket.id);
|
|
@@ -502,22 +937,124 @@ export async function initializeWebSocket(httpServer) {
|
|
|
502
937
|
socketToSession.delete(socket.id);
|
|
503
938
|
socket.leave(`session:${prevSessionId}`);
|
|
504
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
|
+
}
|
|
505
946
|
// Join persistent session room (survives beyond ActiveStream lifecycle)
|
|
506
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);
|
|
507
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
|
+
}
|
|
508
983
|
if (!stream || stream.status !== 'running') {
|
|
509
|
-
|
|
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
|
+
}
|
|
510
1038
|
return;
|
|
511
1039
|
}
|
|
512
|
-
// Add socket to broadcast set (multiple browsers can watch simultaneously)
|
|
513
|
-
stream.sockets.add(socket);
|
|
514
1040
|
socketToSession.set(socket.id, sessionId);
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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 });
|
|
520
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);
|
|
521
1058
|
});
|
|
522
1059
|
// Handle session:leave event — detach socket from current stream and session room
|
|
523
1060
|
// (client navigating away from a session while streaming continues in background)
|
|
@@ -530,12 +1067,133 @@ export async function initializeWebSocket(httpServer) {
|
|
|
530
1067
|
}
|
|
531
1068
|
socketToSession.delete(socket.id);
|
|
532
1069
|
}
|
|
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;
|
|
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);
|
|
536
1073
|
if (roomSessionId) {
|
|
537
1074
|
socket.leave(`session:${roomSessionId}`);
|
|
538
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
|
+
});
|
|
539
1197
|
});
|
|
540
1198
|
// Story 20.1: Dashboard subscribe/unsubscribe
|
|
541
1199
|
socket.on('dashboard:subscribe', () => {
|
|
@@ -610,7 +1268,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
610
1268
|
const lang = socket.data.language || 'en';
|
|
611
1269
|
const t = i18next.getFixedT(lang);
|
|
612
1270
|
// Story 17.5: Security guard
|
|
613
|
-
const access =
|
|
1271
|
+
const access = checkTerminalAccess(socket, lang);
|
|
614
1272
|
if (!access.allowed) {
|
|
615
1273
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:create`);
|
|
616
1274
|
socket.emit('terminal:error', access.error);
|
|
@@ -673,7 +1331,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
673
1331
|
});
|
|
674
1332
|
socket.on('terminal:input', async (data) => {
|
|
675
1333
|
// Story 17.5: Security guard
|
|
676
|
-
const inputAccess =
|
|
1334
|
+
const inputAccess = checkTerminalAccess(socket, socket.data.language || 'en');
|
|
677
1335
|
if (!inputAccess.allowed) {
|
|
678
1336
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:input`);
|
|
679
1337
|
socket.emit('terminal:error', { ...inputAccess.error, terminalId: data.terminalId });
|
|
@@ -689,7 +1347,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
689
1347
|
});
|
|
690
1348
|
socket.on('terminal:resize', async (data) => {
|
|
691
1349
|
// Story 17.5: Security guard
|
|
692
|
-
const resizeAccess =
|
|
1350
|
+
const resizeAccess = checkTerminalAccess(socket, socket.data.language || 'en');
|
|
693
1351
|
if (!resizeAccess.allowed) {
|
|
694
1352
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:resize`);
|
|
695
1353
|
socket.emit('terminal:error', { ...resizeAccess.error, terminalId: data.terminalId });
|
|
@@ -705,7 +1363,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
705
1363
|
});
|
|
706
1364
|
socket.on('terminal:list', async (data) => {
|
|
707
1365
|
const lang = socket.data.language || 'en';
|
|
708
|
-
const access =
|
|
1366
|
+
const access = checkTerminalAccess(socket, lang);
|
|
709
1367
|
if (!access.allowed) {
|
|
710
1368
|
socket.emit('terminal:list', { projectSlug: data.projectSlug, terminals: [] });
|
|
711
1369
|
return;
|
|
@@ -738,7 +1396,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
738
1396
|
});
|
|
739
1397
|
socket.on('terminal:close', async (data) => {
|
|
740
1398
|
// Story 17.5: Security guard
|
|
741
|
-
const closeAccess =
|
|
1399
|
+
const closeAccess = checkTerminalAccess(socket, socket.data.language || 'en');
|
|
742
1400
|
if (!closeAccess.allowed) {
|
|
743
1401
|
log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:close`);
|
|
744
1402
|
socket.emit('terminal:error', { ...closeAccess.error, terminalId: data.terminalId });
|
|
@@ -768,6 +1426,8 @@ export async function initializeWebSocket(httpServer) {
|
|
|
768
1426
|
}
|
|
769
1427
|
socketToSession.delete(socket.id);
|
|
770
1428
|
}
|
|
1429
|
+
socketSessionRoom.delete(socket.id);
|
|
1430
|
+
socketProjectRoom.delete(socket.id);
|
|
771
1431
|
// PTY sessions are NOT cleaned up on socket disconnect.
|
|
772
1432
|
// They persist until explicitly closed by the user, the PTY process exits,
|
|
773
1433
|
// or the server shuts down. This prevents losing long-running terminal
|
|
@@ -833,7 +1493,8 @@ function isSessionNotFoundError(error) {
|
|
|
833
1493
|
return (message.includes('session not found') ||
|
|
834
1494
|
message.includes('session does not exist') ||
|
|
835
1495
|
message.includes('invalid session') ||
|
|
836
|
-
message.includes('no such session')
|
|
1496
|
+
message.includes('no such session') ||
|
|
1497
|
+
message.includes('no conversation found'));
|
|
837
1498
|
}
|
|
838
1499
|
/**
|
|
839
1500
|
* Handle chat:send event from client
|
|
@@ -852,7 +1513,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
852
1513
|
code: ERROR_CODES.VALIDATION_ERROR,
|
|
853
1514
|
message: validation.error,
|
|
854
1515
|
});
|
|
855
|
-
return;
|
|
1516
|
+
return false;
|
|
856
1517
|
}
|
|
857
1518
|
}
|
|
858
1519
|
// Validate workingDirectory exists
|
|
@@ -861,11 +1522,18 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
861
1522
|
code: ERROR_CODES.INVALID_WORKING_DIR,
|
|
862
1523
|
message: t('ws.error.projectPathNotFound'),
|
|
863
1524
|
});
|
|
864
|
-
return;
|
|
1525
|
+
return false;
|
|
865
1526
|
}
|
|
866
1527
|
// 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
|
-
|
|
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
|
+
});
|
|
869
1537
|
const isResuming = resume && sessionId;
|
|
870
1538
|
const sessionService = new SessionService();
|
|
871
1539
|
let timeoutId = null;
|
|
@@ -888,11 +1556,11 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
888
1556
|
// Create canUseTool callback for permission & AskUserQuestion handling
|
|
889
1557
|
// Promise stays pending if socket disconnected — SDK naturally waits
|
|
890
1558
|
const canUseTool = async (toolName, input, options) => {
|
|
891
|
-
// Auto-approve ExitPlanMode when the
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
if (toolName === 'ExitPlanMode' &&
|
|
895
|
-
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');
|
|
896
1564
|
return { behavior: 'allow', updatedInput: input };
|
|
897
1565
|
}
|
|
898
1566
|
const isAskUserQuestion = toolName === 'AskUserQuestion';
|
|
@@ -991,13 +1659,6 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
991
1659
|
}
|
|
992
1660
|
}
|
|
993
1661
|
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
1662
|
emit('error', {
|
|
1002
1663
|
code: ERROR_CODES.CHAT_ERROR,
|
|
1003
1664
|
message: sdkError.message,
|
|
@@ -1007,33 +1668,48 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1007
1668
|
notificationService.notifyError(stream.sessionId, sdkError.message);
|
|
1008
1669
|
}
|
|
1009
1670
|
};
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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;
|
|
1013
1694
|
}
|
|
1014
1695
|
catch (error) {
|
|
1015
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)}`);
|
|
1016
1698
|
if (sdkError instanceof AbortedError || abortController.signal.aborted) {
|
|
1017
1699
|
if (abortController.signal.reason === 'user-abort' || abortController.signal.reason === 'another-client') {
|
|
1018
|
-
return;
|
|
1700
|
+
return false;
|
|
1019
1701
|
}
|
|
1020
1702
|
emit('error', {
|
|
1021
1703
|
code: ERROR_CODES.TIMEOUT_ERROR,
|
|
1022
1704
|
message: t('ws.error.timeout'),
|
|
1023
1705
|
});
|
|
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;
|
|
1706
|
+
return false;
|
|
1032
1707
|
}
|
|
1033
1708
|
emit('error', {
|
|
1034
1709
|
code: ERROR_CODES.CHAT_ERROR,
|
|
1035
1710
|
message: sdkError.message,
|
|
1036
1711
|
});
|
|
1712
|
+
return false;
|
|
1037
1713
|
}
|
|
1038
1714
|
finally {
|
|
1039
1715
|
if (timeoutId) {
|