@tiflis-io/tiflis-code-workstation 0.3.28 → 0.3.30

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.
Files changed (2) hide show
  1. package/dist/main.js +413 -86
  2. package/package.json +3 -1
package/dist/main.js CHANGED
@@ -1398,7 +1398,7 @@ function getMessageType(data) {
1398
1398
  }
1399
1399
 
1400
1400
  // src/infrastructure/websocket/tunnel-client.ts
1401
- var TunnelClient = class {
1401
+ var TunnelClient = class _TunnelClient {
1402
1402
  config;
1403
1403
  callbacks;
1404
1404
  logger;
@@ -1412,6 +1412,8 @@ var TunnelClient = class {
1412
1412
  reconnectTimeout = null;
1413
1413
  registrationTimeout = null;
1414
1414
  messageBuffer = [];
1415
+ static MAX_SEND_RETRIES = 3;
1416
+ static SEND_RETRY_DELAY_MS = 100;
1415
1417
  constructor(config2, callbacks) {
1416
1418
  this.config = config2;
1417
1419
  this.callbacks = callbacks;
@@ -1514,8 +1516,9 @@ var TunnelClient = class {
1514
1516
  this.messageBuffer = [];
1515
1517
  }
1516
1518
  /**
1517
- * Sends a message to the tunnel (for forwarding to clients).
1518
- * Detects send failures and triggers reconnection.
1519
+ * Sends a message to the tunnel (for forwarding to clients) with retry logic (FIX #3).
1520
+ * Buffers during reconnection, retries on transient failures with exponential backoff.
1521
+ * Returns true if send was successful or queued for retry.
1519
1522
  */
1520
1523
  send(message) {
1521
1524
  if (this.state !== "registered" || !this.ws) {
@@ -1528,18 +1531,55 @@ var TunnelClient = class {
1528
1531
  if (this.ws.readyState !== WebSocket.OPEN) {
1529
1532
  this.logger.warn(
1530
1533
  { readyState: this.ws.readyState },
1531
- "Socket not open, triggering reconnection"
1534
+ "Socket not open, buffering message and triggering reconnection"
1532
1535
  );
1536
+ this.messageBuffer.push(message);
1533
1537
  this.handleSendFailure();
1538
+ return true;
1539
+ }
1540
+ return this.sendWithRetry(message, 0);
1541
+ }
1542
+ /**
1543
+ * FIX #3: Send with exponential backoff retry.
1544
+ * On failure, schedules retry with exponential delay (100ms, 200ms, 400ms).
1545
+ */
1546
+ sendWithRetry(message, attempt) {
1547
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1548
+ if (attempt === 0) {
1549
+ this.messageBuffer.push(message);
1550
+ }
1534
1551
  return false;
1535
1552
  }
1536
- this.ws.send(message, (error) => {
1537
- if (error) {
1538
- this.logger.error({ error }, "Send failed, triggering reconnection");
1539
- this.handleSendFailure();
1553
+ try {
1554
+ this.ws.send(message, (error) => {
1555
+ if (error) {
1556
+ if (attempt < _TunnelClient.MAX_SEND_RETRIES) {
1557
+ const delay = _TunnelClient.SEND_RETRY_DELAY_MS * Math.pow(2, attempt);
1558
+ this.logger.debug(
1559
+ { attempt: attempt + 1, delay, errorType: error.name },
1560
+ "Send failed, scheduling retry with exponential backoff"
1561
+ );
1562
+ setTimeout(() => {
1563
+ this.sendWithRetry(message, attempt + 1);
1564
+ }, delay);
1565
+ } else {
1566
+ this.logger.error(
1567
+ { error: error.message, attempts: attempt + 1 },
1568
+ "Send failed after all retries, buffering message"
1569
+ );
1570
+ this.messageBuffer.push(message);
1571
+ this.handleSendFailure();
1572
+ }
1573
+ }
1574
+ });
1575
+ return true;
1576
+ } catch (error) {
1577
+ this.logger.error({ error: error.message }, "Unexpected error in send");
1578
+ if (attempt === 0) {
1579
+ this.messageBuffer.push(message);
1540
1580
  }
1541
- });
1542
- return true;
1581
+ return false;
1582
+ }
1543
1583
  }
1544
1584
  /**
1545
1585
  * Handles send failure by disconnecting and scheduling reconnection.
@@ -3055,39 +3095,32 @@ var HeadlessAgentExecutor = class extends EventEmitter {
3055
3095
  cwd: this.workingDir,
3056
3096
  env: {
3057
3097
  ...shellEnv,
3058
- // Apply alias env vars (e.g., CLAUDE_CONFIG_DIR)
3059
3098
  ...aliasEnvVars,
3060
- // Ensure proper terminal environment
3061
3099
  TERM: "xterm-256color",
3062
- // Disable interactive prompts
3063
3100
  CI: "true"
3064
3101
  },
3065
3102
  stdio: ["ignore", "pipe", "pipe"],
3066
- // stdin ignored, stdout/stderr piped
3067
- detached: true,
3068
- // Create new process group for clean termination
3069
- // Ensure child doesn't interfere with parent's signal handling
3070
- // @ts-expect-error - Node.js 16+ option
3071
- ignoreParentSignals: true
3103
+ detached: true
3072
3104
  });
3073
- this.subprocess.stdout?.on("data", (data) => {
3105
+ const proc = this.subprocess;
3106
+ proc.stdout?.on("data", (data) => {
3074
3107
  if (this.isKilled) return;
3075
3108
  const text2 = data.toString();
3076
3109
  this.emit("stdout", text2);
3077
3110
  });
3078
- this.subprocess.stderr?.on("data", (data) => {
3111
+ proc.stderr?.on("data", (data) => {
3079
3112
  if (this.isKilled) return;
3080
3113
  const text2 = data.toString();
3081
3114
  this.emit("stderr", text2);
3082
3115
  });
3083
- this.subprocess.on("exit", (code) => {
3116
+ proc.on("exit", (code) => {
3084
3117
  this.clearExecutionTimeout();
3085
3118
  if (!this.isKilled) {
3086
3119
  this.emit("exit", code);
3087
3120
  }
3088
3121
  this.subprocess = null;
3089
3122
  });
3090
- this.subprocess.on("error", (error) => {
3123
+ proc.on("error", (error) => {
3091
3124
  this.clearExecutionTimeout();
3092
3125
  if (!this.isKilled) {
3093
3126
  this.emit("error", error);
@@ -5067,13 +5100,58 @@ var SubscriptionService = class {
5067
5100
  };
5068
5101
 
5069
5102
  // src/application/services/message-broadcaster-impl.ts
5070
- var MessageBroadcasterImpl = class {
5103
+ var MessageBroadcasterImpl = class _MessageBroadcasterImpl {
5071
5104
  deps;
5072
5105
  logger;
5106
+ /** FIX #4: Buffer messages for devices during auth flow */
5107
+ authBuffers = /* @__PURE__ */ new Map();
5108
+ static AUTH_BUFFER_TTL_MS = 5e3;
5109
+ // 5 second buffer TTL
5073
5110
  constructor(deps) {
5074
5111
  this.deps = deps;
5075
5112
  this.logger = deps.logger.child({ service: "broadcaster" });
5076
5113
  }
5114
+ /**
5115
+ * FIX #4: Buffers a message for a device during auth flow.
5116
+ * Called when subscribing clients are not yet authenticated.
5117
+ * Messages are buffered with TTL and flushed when auth completes.
5118
+ */
5119
+ bufferMessageForAuth(deviceId, sessionId, message) {
5120
+ if (!this.authBuffers.has(deviceId)) {
5121
+ this.authBuffers.set(deviceId, []);
5122
+ }
5123
+ const buffer = this.authBuffers.get(deviceId) ?? [];
5124
+ buffer.push({ sessionId, message, timestamp: Date.now() });
5125
+ const now = Date.now();
5126
+ const filtered = buffer.filter(
5127
+ (item) => now - item.timestamp < _MessageBroadcasterImpl.AUTH_BUFFER_TTL_MS
5128
+ );
5129
+ this.authBuffers.set(deviceId, filtered);
5130
+ this.logger.debug(
5131
+ { deviceId, bufferSize: filtered.length },
5132
+ "Buffered message for authenticating device"
5133
+ );
5134
+ }
5135
+ /**
5136
+ * FIX #4: Flushes buffered messages for a device after authentication.
5137
+ * Called from main.ts after subscription restore completes.
5138
+ * Returns buffered messages so they can be sent to subscribed sessions.
5139
+ */
5140
+ flushAuthBuffer(deviceId) {
5141
+ const buffer = this.authBuffers.get(deviceId) ?? [];
5142
+ this.authBuffers.delete(deviceId);
5143
+ const now = Date.now();
5144
+ const validMessages = buffer.filter(
5145
+ (item) => now - item.timestamp < _MessageBroadcasterImpl.AUTH_BUFFER_TTL_MS
5146
+ );
5147
+ if (validMessages.length > 0) {
5148
+ this.logger.info(
5149
+ { deviceId, messageCount: validMessages.length },
5150
+ "Flushing auth buffer - delivering buffered messages"
5151
+ );
5152
+ }
5153
+ return validMessages;
5154
+ }
5077
5155
  /**
5078
5156
  * Broadcasts a message to all connected clients via tunnel.
5079
5157
  * The tunnel handles client filtering and delivery.
@@ -5113,21 +5191,97 @@ var MessageBroadcasterImpl = class {
5113
5191
  }
5114
5192
  /**
5115
5193
  * Broadcasts a message to all clients subscribed to a session (by session ID string).
5116
- * Sends targeted messages to each subscriber individually via forward.to_device.
5194
+ * Sends targeted messages to each subscriber in parallel with timeout.
5195
+ * Prevents slow clients from blocking others.
5196
+ *
5197
+ * FIX #4: Also buffers messages for unauthenticated subscribers during auth flow.
5117
5198
  */
5118
- broadcastToSubscribers(sessionId, message) {
5199
+ async broadcastToSubscribers(sessionId, message) {
5119
5200
  const session = new SessionId(sessionId);
5120
5201
  const subscribers = this.deps.clientRegistry.getSubscribers(session);
5121
5202
  const authenticatedSubscribers = subscribers.filter(
5122
5203
  (c) => c.isAuthenticated
5123
5204
  );
5124
5205
  if (authenticatedSubscribers.length === 0) {
5206
+ const unauthenticatedCount = subscribers.length - authenticatedSubscribers.length;
5207
+ if (unauthenticatedCount > 0) {
5208
+ this.logger.debug(
5209
+ { sessionId, unauthenticatedCount },
5210
+ "Subscribers are authenticating - buffering message for delivery after auth"
5211
+ );
5212
+ for (const client of subscribers) {
5213
+ if (!client.isAuthenticated) {
5214
+ this.bufferMessageForAuth(client.deviceId.value, sessionId, message);
5215
+ }
5216
+ }
5217
+ } else {
5218
+ let messageType = "unknown";
5219
+ try {
5220
+ const parsed = JSON.parse(message);
5221
+ messageType = parsed.type ?? "unknown";
5222
+ } catch {
5223
+ }
5224
+ this.logger.debug(
5225
+ { sessionId, messageType },
5226
+ "No subscribers found for session"
5227
+ );
5228
+ }
5125
5229
  return;
5126
5230
  }
5127
- for (const client of authenticatedSubscribers) {
5128
- this.deps.tunnelClient.sendToDevice(client.deviceId.value, message);
5231
+ const SEND_TIMEOUT_MS = 2e3;
5232
+ this.logger.debug(
5233
+ { sessionId, subscriberCount: authenticatedSubscribers.length },
5234
+ "Broadcasting message to subscribers"
5235
+ );
5236
+ const sendPromises = authenticatedSubscribers.map(async (client) => {
5237
+ try {
5238
+ await Promise.race([
5239
+ new Promise((resolve2, reject) => {
5240
+ const sent = this.deps.tunnelClient.sendToDevice(
5241
+ client.deviceId.value,
5242
+ message
5243
+ );
5244
+ if (sent) {
5245
+ resolve2();
5246
+ } else {
5247
+ reject(new Error(`sendToDevice returned false for ${client.deviceId.value}`));
5248
+ }
5249
+ }),
5250
+ new Promise(
5251
+ (_, reject) => setTimeout(
5252
+ () => reject(new Error(`Send timeout for ${client.deviceId.value}`)),
5253
+ SEND_TIMEOUT_MS
5254
+ )
5255
+ )
5256
+ ]);
5257
+ this.logger.debug(
5258
+ { deviceId: client.deviceId.value, sessionId },
5259
+ "Message sent to subscriber"
5260
+ );
5261
+ } catch (error) {
5262
+ this.logger.warn(
5263
+ {
5264
+ deviceId: client.deviceId.value,
5265
+ sessionId,
5266
+ error: error instanceof Error ? error.message : String(error)
5267
+ },
5268
+ "Failed to send to subscriber (timeout or error)"
5269
+ );
5270
+ }
5271
+ });
5272
+ const results = await Promise.allSettled(sendPromises);
5273
+ const failedCount = results.filter((r) => r.status === "rejected").length;
5274
+ if (failedCount > 0) {
5275
+ this.logger.warn(
5276
+ { sessionId, totalSubscribers: authenticatedSubscribers.length, failedCount },
5277
+ "Some subscribers failed to receive message"
5278
+ );
5129
5279
  }
5130
5280
  }
5281
+ getAuthenticatedDeviceIds() {
5282
+ const clients = this.deps.clientRegistry.getAll();
5283
+ return clients.filter((c) => c.isAuthenticated).map((c) => c.deviceId.value);
5284
+ }
5131
5285
  };
5132
5286
 
5133
5287
  // src/infrastructure/persistence/repositories/message-repository.ts
@@ -6619,6 +6773,7 @@ import { EventEmitter as EventEmitter4 } from "events";
6619
6773
  import { ChatOpenAI } from "@langchain/openai";
6620
6774
  import { createReactAgent } from "@langchain/langgraph/prebuilt";
6621
6775
  import { HumanMessage, AIMessage, isAIMessage } from "@langchain/core/messages";
6776
+ import AsyncLock from "async-lock";
6622
6777
 
6623
6778
  // src/infrastructure/agents/supervisor/tools/workspace-tools.ts
6624
6779
  import { tool } from "@langchain/core/tools";
@@ -7524,7 +7679,7 @@ function formatSize(bytes) {
7524
7679
  }
7525
7680
 
7526
7681
  // src/infrastructure/agents/supervisor/supervisor-agent.ts
7527
- var SupervisorAgent = class extends EventEmitter4 {
7682
+ var SupervisorAgent = class _SupervisorAgent extends EventEmitter4 {
7528
7683
  logger;
7529
7684
  agent;
7530
7685
  getMessageBroadcaster;
@@ -7537,6 +7692,11 @@ var SupervisorAgent = class extends EventEmitter4 {
7537
7692
  isProcessingCommand = false;
7538
7693
  /** Timestamp when current execution started (for race condition protection) */
7539
7694
  executionStartedAt = 0;
7695
+ clearContextLock = new AsyncLock();
7696
+ static CLEAR_CONTEXT_TIMEOUT_MS = 1e4;
7697
+ // 10 seconds
7698
+ /** FIX #6: Broadcast acknowledgment tracking */
7699
+ broadcastAcknowledgments = /* @__PURE__ */ new Map();
7540
7700
  constructor(config2) {
7541
7701
  super();
7542
7702
  this.logger = config2.logger.child({ component: "SupervisorAgent" });
@@ -7562,7 +7722,11 @@ var SupervisorAgent = class extends EventEmitter4 {
7562
7722
  config2.workspacesRoot,
7563
7723
  config2.getMessageBroadcaster,
7564
7724
  config2.getChatHistoryService,
7565
- () => this.clearContext(),
7725
+ () => {
7726
+ this.clearContext().catch((error) => {
7727
+ this.logger.error({ error }, "Failed to clear supervisor context");
7728
+ });
7729
+ },
7566
7730
  terminateSessionCallback
7567
7731
  ),
7568
7732
  ...createFilesystemTools(config2.workspacesRoot)
@@ -7824,27 +7988,86 @@ var SupervisorAgent = class extends EventEmitter4 {
7824
7988
  this.logger.info("Global conversation history cleared");
7825
7989
  }
7826
7990
  /**
7827
- * Clears supervisor context completely:
7828
- * - In-memory conversation history
7829
- * - Persistent history in database
7830
- * - Notifies all connected clients
7991
+ * Clears supervisor context completely with multi-device synchronization:
7992
+ * - Acquires lock to prevent concurrent execution during clear
7993
+ * - Cancels active execution streams
7994
+ * - Clears in-memory conversation history
7995
+ * - Clears persistent history in database
7996
+ * - Broadcasts notification to all clients with ack tracking (FIX #6, #9)
7997
+ */
7998
+ async clearContext() {
7999
+ await this.clearContextLock.acquire("clear", async () => {
8000
+ this.logger.info({ isExecuting: this.isExecuting }, "Acquiring lock for context clear");
8001
+ if (this.isExecuting || this.isProcessingCommand) {
8002
+ this.logger.info("Active execution detected, cancelling before clear");
8003
+ this.cancel();
8004
+ await new Promise((resolve2) => setTimeout(resolve2, 50));
8005
+ }
8006
+ this.conversationHistory = [];
8007
+ this.isCancelled = false;
8008
+ const chatHistoryService = this.getChatHistoryService?.();
8009
+ if (chatHistoryService) {
8010
+ try {
8011
+ chatHistoryService.clearSupervisorHistory();
8012
+ this.logger.debug("Persistent supervisor history cleared");
8013
+ } catch (error) {
8014
+ this.logger.error({ error }, "Failed to clear persistent supervisor history");
8015
+ }
8016
+ }
8017
+ const broadcaster = this.getMessageBroadcaster?.();
8018
+ if (broadcaster) {
8019
+ const broadcastId = `clear_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
8020
+ const clearNotification = JSON.stringify({
8021
+ type: "supervisor.context_cleared",
8022
+ payload: { timestamp: Date.now(), broadcast_id: broadcastId }
8023
+ });
8024
+ try {
8025
+ const deviceIds = new Set(broadcaster.getAuthenticatedDeviceIds());
8026
+ if (deviceIds.size > 0) {
8027
+ this.broadcastAcknowledgments.set(broadcastId, {
8028
+ deviceIds,
8029
+ timestamp: Date.now(),
8030
+ timeout: setTimeout(() => {
8031
+ const ack = this.broadcastAcknowledgments.get(broadcastId);
8032
+ if (ack && ack.deviceIds.size > 0) {
8033
+ this.logger.warn({
8034
+ broadcastId,
8035
+ missingDevices: Array.from(ack.deviceIds)
8036
+ }, "Context clear broadcast - missing acks from devices");
8037
+ }
8038
+ this.broadcastAcknowledgments.delete(broadcastId);
8039
+ }, 5e3)
8040
+ // 5 second timeout
8041
+ });
8042
+ }
8043
+ broadcaster.broadcastToAll(clearNotification);
8044
+ this.logger.info({
8045
+ broadcastId,
8046
+ deviceCount: deviceIds.size
8047
+ }, "Broadcasted context cleared notification to all clients");
8048
+ } catch (error) {
8049
+ this.logger.error({ error }, "Failed to broadcast context cleared");
8050
+ }
8051
+ } else {
8052
+ this.logger.warn("MessageBroadcaster not available");
8053
+ }
8054
+ this.logger.info("Supervisor context cleared (lock released)");
8055
+ }, { timeout: _SupervisorAgent.CLEAR_CONTEXT_TIMEOUT_MS });
8056
+ }
8057
+ /**
8058
+ * FIX #6: Records acknowledgment from a device for context clear broadcast.
7831
8059
  */
7832
- clearContext() {
7833
- this.conversationHistory = [];
7834
- this.isCancelled = false;
7835
- const chatHistoryService = this.getChatHistoryService?.();
7836
- if (chatHistoryService) {
7837
- chatHistoryService.clearSupervisorHistory();
7838
- }
7839
- const broadcaster = this.getMessageBroadcaster?.();
7840
- if (broadcaster) {
7841
- const clearNotification = JSON.stringify({
7842
- type: "supervisor.context_cleared",
7843
- payload: { timestamp: Date.now() }
7844
- });
7845
- broadcaster.broadcastToAll(clearNotification);
8060
+ recordClearAck(broadcastId, deviceId) {
8061
+ const ack = this.broadcastAcknowledgments.get(broadcastId);
8062
+ if (ack) {
8063
+ ack.deviceIds.delete(deviceId);
8064
+ this.logger.debug({ broadcastId, deviceId, remaining: ack.deviceIds.size }, "Received clear ack");
8065
+ if (ack.deviceIds.size === 0) {
8066
+ clearTimeout(ack.timeout);
8067
+ this.broadcastAcknowledgments.delete(broadcastId);
8068
+ this.logger.info({ broadcastId }, "All devices acknowledged context clear");
8069
+ }
7846
8070
  }
7847
- this.logger.info("Supervisor context cleared (in-memory, persistent, and clients notified)");
7848
8071
  }
7849
8072
  /**
7850
8073
  * Resets the cancellation state.
@@ -8339,9 +8562,15 @@ var MockSupervisorAgent = class extends EventEmitter5 {
8339
8562
  getConversationHistory() {
8340
8563
  return [...this.conversationHistory];
8341
8564
  }
8342
- /**
8343
- * Sleep utility.
8344
- */
8565
+ clearContext() {
8566
+ this.conversationHistory = [];
8567
+ this.isCancelled = false;
8568
+ this.logger.info("Mock context cleared");
8569
+ return Promise.resolve();
8570
+ }
8571
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
8572
+ recordClearAck(_broadcastId, _deviceId) {
8573
+ }
8345
8574
  sleep(ms) {
8346
8575
  return new Promise((resolve2) => setTimeout(resolve2, ms));
8347
8576
  }
@@ -9145,7 +9374,7 @@ function handleTunnelMessage(rawMessage, tunnelClient, messageBroadcaster, creat
9145
9374
  );
9146
9375
  }
9147
9376
  }
9148
- function handleAuthMessageViaTunnel(data, tunnelClient, authenticateClient, logger, subscriptionService) {
9377
+ function handleAuthMessageViaTunnel(data, tunnelClient, authenticateClient, logger, subscriptionService, messageBroadcaster) {
9149
9378
  const authResult = AuthMessageSchema.safeParse(data);
9150
9379
  if (!authResult.success) {
9151
9380
  logger.warn(
@@ -9175,6 +9404,32 @@ function handleAuthMessageViaTunnel(data, tunnelClient, authenticateClient, logg
9175
9404
  );
9176
9405
  }
9177
9406
  }
9407
+ if (messageBroadcaster) {
9408
+ const bufferedMessages = messageBroadcaster.flushAuthBuffer(deviceId);
9409
+ if (bufferedMessages.length > 0) {
9410
+ logger.info(
9411
+ { deviceId, messageCount: bufferedMessages.length },
9412
+ "Flushing buffered messages after subscription restore"
9413
+ );
9414
+ (async () => {
9415
+ for (const bufferedMsg of bufferedMessages) {
9416
+ try {
9417
+ await messageBroadcaster.broadcastToSubscribers(
9418
+ bufferedMsg.sessionId,
9419
+ bufferedMsg.message
9420
+ );
9421
+ } catch (error) {
9422
+ logger.error(
9423
+ { error, deviceId, sessionId: bufferedMsg.sessionId },
9424
+ "Failed to send buffered message"
9425
+ );
9426
+ }
9427
+ }
9428
+ })().catch((error) => {
9429
+ logger.error({ error, deviceId }, "Error flushing buffered messages");
9430
+ });
9431
+ }
9432
+ }
9178
9433
  const responseJson = JSON.stringify(result);
9179
9434
  if (tunnelClient.send(responseJson)) {
9180
9435
  logger.info(
@@ -9413,7 +9668,7 @@ async function bootstrap() {
9413
9668
  }
9414
9669
  };
9415
9670
  const createMessageHandlers = () => ({
9416
- auth: (socket, message) => {
9671
+ auth: async (socket, message) => {
9417
9672
  const authMessage = message;
9418
9673
  const deviceId = authMessage.payload.device_id;
9419
9674
  const result = authenticateClient.execute({
@@ -9431,6 +9686,21 @@ async function bootstrap() {
9431
9686
  );
9432
9687
  }
9433
9688
  }
9689
+ if (broadcaster) {
9690
+ const bufferedMessages = broadcaster.flushAuthBuffer(deviceId);
9691
+ if (bufferedMessages.length > 0) {
9692
+ logger.info(
9693
+ { deviceId, messageCount: bufferedMessages.length },
9694
+ "Flushing buffered messages after subscription restore"
9695
+ );
9696
+ for (const bufferedMsg of bufferedMessages) {
9697
+ await broadcaster.broadcastToSubscribers(
9698
+ bufferedMsg.sessionId,
9699
+ bufferedMsg.message
9700
+ );
9701
+ }
9702
+ }
9703
+ }
9434
9704
  sendToDevice(socket, deviceId, JSON.stringify(result));
9435
9705
  return Promise.resolve();
9436
9706
  },
@@ -9875,13 +10145,13 @@ async function bootstrap() {
9875
10145
  return Promise.resolve();
9876
10146
  },
9877
10147
  // Clear supervisor conversation history (global)
9878
- "supervisor.clear_context": (socket, message) => {
10148
+ "supervisor.clear_context": async (socket, message) => {
9879
10149
  const clearMessage = message;
9880
10150
  const directClient = clientRegistry.getBySocket(socket);
9881
10151
  const tunnelClient2 = clearMessage.device_id ? clientRegistry.getByDeviceId(new DeviceId(clearMessage.device_id)) : void 0;
9882
10152
  const isAuthenticated = directClient?.isAuthenticated ?? tunnelClient2?.isAuthenticated;
9883
10153
  if (isAuthenticated) {
9884
- supervisorAgent.clearContext();
10154
+ await supervisorAgent.clearContext();
9885
10155
  sendToDevice(
9886
10156
  socket,
9887
10157
  clearMessage.device_id,
@@ -10132,7 +10402,7 @@ async function bootstrap() {
10132
10402
  message_id: messageId
10133
10403
  }
10134
10404
  };
10135
- messageBroadcaster.broadcastToSubscribers(
10405
+ await messageBroadcaster.broadcastToSubscribers(
10136
10406
  sessionId,
10137
10407
  JSON.stringify(errorEvent)
10138
10408
  );
@@ -10168,7 +10438,7 @@ async function bootstrap() {
10168
10438
  from_device_id: deviceId
10169
10439
  }
10170
10440
  };
10171
- messageBroadcaster.broadcastToSubscribers(
10441
+ await messageBroadcaster.broadcastToSubscribers(
10172
10442
  sessionId,
10173
10443
  JSON.stringify(transcriptionEvent)
10174
10444
  );
@@ -10259,7 +10529,7 @@ async function bootstrap() {
10259
10529
  timestamp: Date.now()
10260
10530
  }
10261
10531
  };
10262
- messageBroadcaster.broadcastToSubscribers(
10532
+ await messageBroadcaster.broadcastToSubscribers(
10263
10533
  sessionId,
10264
10534
  JSON.stringify(errorEvent)
10265
10535
  );
@@ -10387,14 +10657,20 @@ async function bootstrap() {
10387
10657
  is_complete: true
10388
10658
  }
10389
10659
  };
10390
- messageBroadcaster.broadcastToSubscribers(
10660
+ void messageBroadcaster.broadcastToSubscribers(
10391
10661
  sessionId,
10392
10662
  JSON.stringify(cancelOutput)
10393
- );
10394
- logger.info(
10395
- { sessionId },
10396
- "Broadcasted cancel message to subscribers"
10397
- );
10663
+ ).then(() => {
10664
+ logger.info(
10665
+ { sessionId },
10666
+ "Broadcasted cancel message to subscribers"
10667
+ );
10668
+ }).catch((error) => {
10669
+ logger.error(
10670
+ { sessionId, error },
10671
+ "Failed to broadcast cancel message"
10672
+ );
10673
+ });
10398
10674
  chatHistoryService.saveAgentMessage(sessionId, "assistant", "", [
10399
10675
  cancelBlock
10400
10676
  ]);
@@ -10827,8 +11103,27 @@ async function bootstrap() {
10827
11103
  tunnelClient,
10828
11104
  authenticateClient,
10829
11105
  logger,
10830
- subscriptionService
11106
+ subscriptionService,
11107
+ broadcaster
10831
11108
  );
11109
+ } else if (messageType === "supervisor.context_cleared.ack") {
11110
+ try {
11111
+ const ackData = data;
11112
+ const broadcastId = ackData.payload?.broadcast_id;
11113
+ const deviceId = ackData.payload?.device_id;
11114
+ if (broadcastId && deviceId && supervisorAgent) {
11115
+ supervisorAgent.recordClearAck(broadcastId, deviceId);
11116
+ logger.debug(
11117
+ { broadcastId, deviceId },
11118
+ "Recorded context clear acknowledgment"
11119
+ );
11120
+ }
11121
+ } catch (ackError) {
11122
+ logger.warn(
11123
+ { error: ackError, message: message.slice(0, 100) },
11124
+ "Failed to process context clear acknowledgment"
11125
+ );
11126
+ }
10832
11127
  } else {
10833
11128
  handleTunnelMessage(
10834
11129
  message,
@@ -10880,9 +11175,13 @@ async function bootstrap() {
10880
11175
  subscriptionRepository,
10881
11176
  logger
10882
11177
  });
11178
+ let supervisorMessageSequence = 0;
11179
+ const agentMessageSequences = /* @__PURE__ */ new Map();
10883
11180
  const broadcaster = messageBroadcaster;
10884
11181
  const agentMessageAccumulator = /* @__PURE__ */ new Map();
10885
11182
  const agentStreamingMessageIds = /* @__PURE__ */ new Map();
11183
+ const STREAMING_STATE_GRACE_PERIOD_MS = 1e4;
11184
+ const agentCleanupTimeouts = /* @__PURE__ */ new Map();
10886
11185
  const getOrCreateAgentStreamingMessageId = (sessionId) => {
10887
11186
  let messageId = agentStreamingMessageIds.get(sessionId);
10888
11187
  if (!messageId) {
@@ -10892,7 +11191,16 @@ async function bootstrap() {
10892
11191
  return messageId;
10893
11192
  };
10894
11193
  const clearAgentStreamingMessageId = (sessionId) => {
10895
- agentStreamingMessageIds.delete(sessionId);
11194
+ const existingTimeout = agentCleanupTimeouts.get(sessionId);
11195
+ if (existingTimeout) {
11196
+ clearTimeout(existingTimeout);
11197
+ }
11198
+ const timeout = setTimeout(() => {
11199
+ agentStreamingMessageIds.delete(sessionId);
11200
+ agentCleanupTimeouts.delete(sessionId);
11201
+ logger.debug({ sessionId }, "Agent streaming state cleaned up after grace period");
11202
+ }, STREAMING_STATE_GRACE_PERIOD_MS);
11203
+ agentCleanupTimeouts.set(sessionId, timeout);
10896
11204
  };
10897
11205
  agentSessionManager.on(
10898
11206
  "blocks",
@@ -10966,10 +11274,14 @@ async function bootstrap() {
10966
11274
  }
10967
11275
  const mergedBlocks = mergeToolBlocks(accumulatedBlocks);
10968
11276
  const fullAccumulatedText = mergedBlocks.filter((b) => b.block_type === "text").map((b) => b.content).join("\n");
11277
+ const currentSequence = agentMessageSequences.get(sessionId) ?? 0;
11278
+ const newSequence = currentSequence + 1;
11279
+ agentMessageSequences.set(sessionId, newSequence);
10969
11280
  const outputEvent = {
10970
11281
  type: "session.output",
10971
11282
  session_id: sessionId,
10972
11283
  streaming_message_id: streamingMessageId,
11284
+ sequence: newSequence,
10973
11285
  payload: {
10974
11286
  content_type: "agent",
10975
11287
  content: fullAccumulatedText,
@@ -10980,13 +11292,19 @@ async function bootstrap() {
10980
11292
  is_complete: isComplete
10981
11293
  }
10982
11294
  };
10983
- broadcaster.broadcastToSubscribers(
11295
+ await broadcaster.broadcastToSubscribers(
10984
11296
  sessionId,
10985
11297
  JSON.stringify(outputEvent)
10986
11298
  );
10987
11299
  if (isComplete) {
10988
11300
  clearAgentStreamingMessageId(sessionId);
10989
- agentMessageAccumulator.delete(sessionId);
11301
+ setTimeout(() => {
11302
+ agentMessageAccumulator.delete(sessionId);
11303
+ logger.debug(
11304
+ { sessionId, messageId: streamingMessageId },
11305
+ "Agent message accumulator cleaned up after grace period"
11306
+ );
11307
+ }, STREAMING_STATE_GRACE_PERIOD_MS);
10990
11308
  }
10991
11309
  if (isComplete && fullTextContent.length > 0) {
10992
11310
  const pendingVoiceCommand = pendingAgentVoiceCommands.get(sessionId);
@@ -11045,7 +11363,7 @@ async function bootstrap() {
11045
11363
  from_device_id: originDeviceId
11046
11364
  }
11047
11365
  };
11048
- broadcaster.broadcastToSubscribers(
11366
+ await broadcaster.broadcastToSubscribers(
11049
11367
  sessionId,
11050
11368
  JSON.stringify(voiceOutputEvent)
11051
11369
  );
@@ -11090,6 +11408,7 @@ async function bootstrap() {
11090
11408
  const outputEvent = {
11091
11409
  type: "supervisor.output",
11092
11410
  streaming_message_id: streamingMessageId,
11411
+ sequence: ++supervisorMessageSequence,
11093
11412
  payload: {
11094
11413
  content_type: "supervisor",
11095
11414
  content: textContent,
@@ -11101,7 +11420,13 @@ async function bootstrap() {
11101
11420
  const message = JSON.stringify(outputEvent);
11102
11421
  broadcaster.broadcastToAll(message);
11103
11422
  if (isComplete) {
11104
- supervisorMessageAccumulator.clear();
11423
+ setTimeout(() => {
11424
+ supervisorMessageAccumulator.clear();
11425
+ logger.debug(
11426
+ { messageId: streamingMessageId },
11427
+ "Supervisor message accumulator cleaned up after grace period"
11428
+ );
11429
+ }, STREAMING_STATE_GRACE_PERIOD_MS);
11105
11430
  }
11106
11431
  if (isComplete && finalOutput && finalOutput.length > 0) {
11107
11432
  chatHistoryService.saveSupervisorMessage(
@@ -11230,21 +11555,23 @@ async function bootstrap() {
11230
11555
  batchIntervalMs: env.TERMINAL_BATCH_INTERVAL_MS,
11231
11556
  maxBatchSize: env.TERMINAL_BATCH_MAX_SIZE,
11232
11557
  onFlush: (batchedData) => {
11233
- const outputMessage = session.addOutputToBuffer(batchedData);
11234
- const outputEvent = {
11235
- type: "session.output",
11236
- session_id: sessionId.value,
11237
- payload: {
11238
- content_type: "terminal",
11239
- content: batchedData,
11240
- timestamp: outputMessage.timestamp,
11241
- sequence: outputMessage.sequence
11242
- }
11243
- };
11244
- broadcaster.broadcastToSubscribers(
11245
- sessionId.value,
11246
- JSON.stringify(outputEvent)
11247
- );
11558
+ void (async () => {
11559
+ const outputMessage = session.addOutputToBuffer(batchedData);
11560
+ const outputEvent = {
11561
+ type: "session.output",
11562
+ session_id: sessionId.value,
11563
+ payload: {
11564
+ content_type: "terminal",
11565
+ content: batchedData,
11566
+ timestamp: outputMessage.timestamp,
11567
+ sequence: outputMessage.sequence
11568
+ }
11569
+ };
11570
+ await broadcaster.broadcastToSubscribers(
11571
+ sessionId.value,
11572
+ JSON.stringify(outputEvent)
11573
+ );
11574
+ })();
11248
11575
  }
11249
11576
  });
11250
11577
  session.onOutput((data) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiflis-io/tiflis-code-workstation",
3
- "version": "0.3.28",
3
+ "version": "0.3.30",
4
4
  "description": "Workstation server for tiflis-code - manages agent sessions and terminal access",
5
5
  "author": "Roman Barinov <rbarinov@gmail.com>",
6
6
  "license": "FSL-1.1-NC",
@@ -36,6 +36,7 @@
36
36
  "@langchain/core": "^0.3.26",
37
37
  "@langchain/langgraph": "^0.2.42",
38
38
  "@langchain/openai": "^0.3.17",
39
+ "async-lock": "^1.4.0",
39
40
  "better-sqlite3": "^11.7.0",
40
41
  "dotenv": "^16.4.7",
41
42
  "drizzle-orm": "^0.38.3",
@@ -51,6 +52,7 @@
51
52
  },
52
53
  "devDependencies": {
53
54
  "@eslint/js": "^9.17.0",
55
+ "@types/async-lock": "^1.4.2",
54
56
  "@types/better-sqlite3": "^7.6.12",
55
57
  "@types/node": "^22.10.2",
56
58
  "@types/ws": "^8.5.13",