cowork-dash 0.1.5__py3-none-any.whl → 0.1.7__py3-none-any.whl

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.
cowork_dash/app.py CHANGED
@@ -4,12 +4,12 @@ import sys
4
4
  import json
5
5
  import base64
6
6
  import re
7
+ import copy
7
8
  import shutil
8
9
  import platform
9
10
  import subprocess
10
11
  import threading
11
12
  import time
12
- import argparse
13
13
  import importlib.util
14
14
  from pathlib import Path
15
15
  from datetime import datetime
@@ -17,20 +17,20 @@ from typing import Optional, Dict, Any, List
17
17
  from dotenv import load_dotenv
18
18
  load_dotenv()
19
19
 
20
- from dash import Dash, html, Input, Output, State, callback_context, no_update, ALL, clientside_callback
20
+ from dash import Dash, html, Input, Output, State, callback_context, no_update, ALL
21
21
  from dash.exceptions import PreventUpdate
22
22
  import dash_mantine_components as dmc
23
23
  from dash_iconify import DashIconify
24
24
 
25
25
  # Import custom modules
26
- from .canvas import parse_canvas_object, export_canvas_to_markdown, load_canvas_from_markdown
26
+ from .canvas import export_canvas_to_markdown, load_canvas_from_markdown
27
27
  from .file_utils import build_file_tree, render_file_tree, read_file_content, get_file_download_data, load_folder_contents
28
28
  from .components import (
29
- format_message, format_loading, format_thinking, format_todos,
30
- format_todos_inline, render_canvas_items, format_tool_calls_inline,
29
+ format_message, format_loading, format_thinking, format_todos_inline, render_canvas_items, format_tool_calls_inline,
31
30
  format_interrupt
32
31
  )
33
32
  from .layout import create_layout as create_layout_component
33
+ from .virtual_fs import get_session_manager
34
34
 
35
35
  # Import configuration defaults
36
36
  from . import config
@@ -38,88 +38,6 @@ from . import config
38
38
  # Generate thread ID
39
39
  thread_id = str(uuid.uuid4())
40
40
 
41
- # Parse command-line arguments early
42
- def parse_args():
43
- """Parse command-line arguments."""
44
- parser = argparse.ArgumentParser(
45
- description="FastDash Browser - AI Agent Web Interface",
46
- formatter_class=argparse.RawDescriptionHelpFormatter,
47
- epilog="""
48
- Examples:
49
- # Use defaults from config.py
50
- python app.py
51
-
52
- # Override workspace and port
53
- python app.py --workspace ~/my-workspace --port 8080
54
-
55
- # Use custom agent from file
56
- python app.py --agent my_agents.py:my_agent
57
-
58
- # Production mode
59
- python app.py --host 0.0.0.0 --port 80 --no-debug
60
-
61
- # Debug mode with custom workspace
62
- python app.py --debug --workspace /tmp/test-workspace
63
- """
64
- )
65
-
66
- parser.add_argument(
67
- "--workspace",
68
- type=str,
69
- help="Workspace directory path (default: from config.py)"
70
- )
71
-
72
- parser.add_argument(
73
- "--agent",
74
- type=str,
75
- metavar="PATH:OBJECT",
76
- help='Agent specification as "path/to/file.py:object_name" (e.g., "agent.py:agent" or "my_agents.py:custom_agent")'
77
- )
78
-
79
- parser.add_argument(
80
- "--port",
81
- type=int,
82
- help="Port to run on (default: from config.py)"
83
- )
84
-
85
- parser.add_argument(
86
- "--host",
87
- type=str,
88
- help="Host to bind to (default: from config.py)"
89
- )
90
-
91
- parser.add_argument(
92
- "--debug",
93
- action="store_true",
94
- help="Enable debug mode"
95
- )
96
-
97
- parser.add_argument(
98
- "--no-debug",
99
- action="store_true",
100
- help="Disable debug mode"
101
- )
102
-
103
- parser.add_argument(
104
- "--title",
105
- type=str,
106
- help="Application title (default: from config.py)"
107
- )
108
-
109
- parser.add_argument(
110
- "--subtitle",
111
- type=str,
112
- help="Application subtitle (default: from config.py)"
113
- )
114
-
115
- parser.add_argument(
116
- "--config",
117
- type=str,
118
- help="Path to configuration file (default: config.py)"
119
- )
120
-
121
- return parser.parse_args()
122
-
123
41
  def load_agent_from_spec(agent_spec: str):
124
42
  """
125
43
  Load agent from specification string.
@@ -202,14 +120,56 @@ PORT = config.PORT
202
120
  HOST = config.HOST
203
121
  DEBUG = config.DEBUG
204
122
  WELCOME_MESSAGE = config.WELCOME_MESSAGE
123
+ USE_VIRTUAL_FS = config.VIRTUAL_FS # Can be overridden by --virtual-fs CLI arg
205
124
 
206
- # Ensure workspace exists
207
- WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
125
+ # Ensure workspace exists (only for physical filesystem mode)
126
+ if not USE_VIRTUAL_FS:
127
+ WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
208
128
 
209
129
  # Initialize agent from config
210
130
  agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
211
131
 
212
132
 
133
+ def get_workspace_for_session(session_id: Optional[str] = None):
134
+ """Get the workspace root for a session.
135
+
136
+ In virtual filesystem mode, returns a VirtualFilesystem for the session.
137
+ In physical mode, returns the WORKSPACE_ROOT Path.
138
+
139
+ Args:
140
+ session_id: Session ID (required for virtual FS mode, ignored otherwise)
141
+
142
+ Returns:
143
+ Path or VirtualFilesystem depending on USE_VIRTUAL_FS setting
144
+ """
145
+ if USE_VIRTUAL_FS:
146
+ if not session_id:
147
+ # Generate a new session if none provided
148
+ session_id = get_session_manager().create_session()
149
+ else:
150
+ # Get or create session
151
+ session_id = get_session_manager().get_or_create_session(session_id)
152
+ return get_session_manager().get_filesystem(session_id)
153
+ else:
154
+ return WORKSPACE_ROOT
155
+
156
+
157
+ def get_or_create_session_id(existing_id: Optional[str] = None) -> str:
158
+ """Get existing session ID or create a new one.
159
+
160
+ Args:
161
+ existing_id: Existing session ID from cookie/store
162
+
163
+ Returns:
164
+ Valid session ID
165
+ """
166
+ if USE_VIRTUAL_FS:
167
+ return get_session_manager().get_or_create_session(existing_id)
168
+ else:
169
+ # In physical mode, still track session IDs but they all share the same workspace
170
+ return existing_id or str(uuid.uuid4())
171
+
172
+
213
173
  # =============================================================================
214
174
  # STYLING
215
175
  # =============================================================================
@@ -277,13 +237,13 @@ def get_colors(theme: str = "light") -> dict:
277
237
  # AGENT INTERACTION - WITH REAL-TIME STREAMING
278
238
  # =============================================================================
279
239
 
280
- # Global state for streaming updates
240
+ # Global state for streaming updates (used in physical FS mode)
281
241
  _agent_state = {
282
242
  "running": False,
283
243
  "thinking": "",
284
244
  "todos": [],
285
245
  "tool_calls": [], # Current turn's tool calls (reset each turn)
286
- "canvas": load_canvas_from_markdown(WORKSPACE_ROOT), # Load from canvas.md if exists
246
+ "canvas": load_canvas_from_markdown(WORKSPACE_ROOT) if not USE_VIRTUAL_FS else [], # Load from canvas.md if exists (physical FS only)
287
247
  "response": "",
288
248
  "error": None,
289
249
  "interrupt": None, # Track interrupt requests for human-in-the-loop
@@ -293,25 +253,110 @@ _agent_state = {
293
253
  }
294
254
  _agent_state_lock = threading.Lock()
295
255
 
256
+ # Session-aware state for virtual FS mode
257
+ # Each session gets its own agent instance and state
258
+ _session_agents: Dict[str, Any] = {}
259
+ _session_agent_states: Dict[str, Dict[str, Any]] = {}
260
+ _session_agents_lock = threading.Lock()
261
+
262
+
263
+ def _get_default_agent_state() -> Dict[str, Any]:
264
+ """Return a fresh default agent state dict."""
265
+ return {
266
+ "running": False,
267
+ "thinking": "",
268
+ "todos": [],
269
+ "tool_calls": [],
270
+ "canvas": [],
271
+ "response": "",
272
+ "error": None,
273
+ "interrupt": None,
274
+ "last_update": time.time(),
275
+ "start_time": None,
276
+ "stop_requested": False,
277
+ }
296
278
 
297
- def request_agent_stop():
298
- """Request the agent to stop execution."""
299
- with _agent_state_lock:
300
- _agent_state["stop_requested"] = True
301
- _agent_state["last_update"] = time.time()
302
279
 
303
- def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None):
304
- """Run agent in background thread and update global state in real-time.
280
+ def _get_session_agent(session_id: str):
281
+ """Get or create agent for a session (virtual FS mode only).
282
+
283
+ Args:
284
+ session_id: The session ID.
285
+
286
+ Returns:
287
+ The agent instance for this session.
288
+ """
289
+ from .agent import create_session_agent
290
+
291
+ with _session_agents_lock:
292
+ if session_id not in _session_agents:
293
+ _session_agents[session_id] = create_session_agent(session_id)
294
+ return _session_agents[session_id]
295
+
296
+
297
+ def _get_session_state(session_id: str) -> Dict[str, Any]:
298
+ """Get or create agent state for a session (virtual FS mode only).
299
+
300
+ Args:
301
+ session_id: The session ID.
302
+
303
+ Returns:
304
+ The agent state dict for this session.
305
+ """
306
+ with _session_agents_lock:
307
+ if session_id not in _session_agent_states:
308
+ _session_agent_states[session_id] = _get_default_agent_state()
309
+ return _session_agent_states[session_id]
310
+
311
+
312
+ def _get_session_state_lock() -> threading.Lock:
313
+ """Get the lock for session state access."""
314
+ return _session_agents_lock
315
+
316
+
317
+ def request_agent_stop(session_id: Optional[str] = None):
318
+ """Request the agent to stop execution.
319
+
320
+ Args:
321
+ session_id: Session ID for virtual FS mode, None for physical FS mode.
322
+ """
323
+ if USE_VIRTUAL_FS and session_id:
324
+ state = _get_session_state(session_id)
325
+ with _session_agents_lock:
326
+ state["stop_requested"] = True
327
+ state["last_update"] = time.time()
328
+ else:
329
+ with _agent_state_lock:
330
+ _agent_state["stop_requested"] = True
331
+ _agent_state["last_update"] = time.time()
332
+
333
+
334
+ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None, session_id: Optional[str] = None):
335
+ """Run agent in background thread and update state in real-time.
305
336
 
306
337
  Args:
307
338
  message: User message to send to agent
308
339
  resume_data: Optional dict with 'decisions' to resume from interrupt
309
340
  workspace_path: Current workspace directory path to inject into agent context
341
+ session_id: Session ID for virtual FS mode (determines which agent and state to use)
310
342
  """
311
- if not agent:
312
- with _agent_state_lock:
313
- _agent_state["response"] = f"⚠️ {_agent_state['error']}\n\nPlease check your setup and try again."
314
- _agent_state["running"] = False
343
+ # Determine which agent and state to use based on mode
344
+ if USE_VIRTUAL_FS and session_id:
345
+ current_agent = _get_session_agent(session_id)
346
+ current_state = _get_session_state(session_id)
347
+ state_lock = _session_agents_lock
348
+ # Use session_id as thread_id for LangGraph checkpointing
349
+ current_thread_id = session_id
350
+ else:
351
+ current_agent = agent
352
+ current_state = _agent_state
353
+ state_lock = _agent_state_lock
354
+ current_thread_id = thread_id
355
+
356
+ if not current_agent:
357
+ with state_lock:
358
+ current_state["response"] = f"⚠️ {current_state.get('error', 'No agent available')}\n\nPlease check your setup and try again."
359
+ current_state["running"] = False
315
360
  return
316
361
 
317
362
  # Track tool calls by their ID for updating status
@@ -338,17 +383,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
338
383
 
339
384
  def _update_tool_call_result(tool_call_id: str, result: Any, status: str = "success"):
340
385
  """Update a tool call with its result."""
341
- with _agent_state_lock:
342
- for tc in _agent_state["tool_calls"]:
386
+ with state_lock:
387
+ for tc in current_state["tool_calls"]:
343
388
  if tc.get("id") == tool_call_id:
344
389
  tc["result"] = result
345
390
  tc["status"] = status
346
391
  break
347
- _agent_state["last_update"] = time.time()
392
+ current_state["last_update"] = time.time()
393
+
394
+ # Set tool session context for virtual FS mode
395
+ # This allows tools like add_to_canvas to access the session's VirtualFilesystem
396
+ from .tools import set_tool_session_context, clear_tool_session_context
397
+ if USE_VIRTUAL_FS and session_id:
398
+ set_tool_session_context(session_id)
348
399
 
349
400
  try:
350
401
  # Prepare input based on whether we're resuming or starting fresh
351
- stream_config = dict(configurable=dict(thread_id=thread_id))
402
+ stream_config = dict(configurable=dict(thread_id=current_thread_id))
352
403
 
353
404
  if message == "__RESUME__":
354
405
  # Resume from interrupt
@@ -363,24 +414,24 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
363
414
  message_with_context = message
364
415
  agent_input = {"messages": [{"role": "user", "content": message_with_context}]}
365
416
 
366
- for update in agent.stream(agent_input, stream_mode="updates", config=stream_config):
417
+ for update in current_agent.stream(agent_input, stream_mode="updates", config=stream_config):
367
418
  # Check if stop was requested
368
- with _agent_state_lock:
369
- if _agent_state.get("stop_requested"):
370
- _agent_state["response"] = _agent_state.get("response", "") + "\n\nAgent stopped by user."
371
- _agent_state["running"] = False
372
- _agent_state["stop_requested"] = False
373
- _agent_state["last_update"] = time.time()
419
+ with state_lock:
420
+ if current_state.get("stop_requested"):
421
+ current_state["response"] = current_state.get("response", "") + "\n\nAgent stopped by user."
422
+ current_state["running"] = False
423
+ current_state["stop_requested"] = False
424
+ current_state["last_update"] = time.time()
374
425
  return
375
426
 
376
427
  # Check for interrupt
377
428
  if isinstance(update, dict) and "__interrupt__" in update:
378
429
  interrupt_value = update["__interrupt__"]
379
430
  interrupt_data = _process_interrupt(interrupt_value)
380
- with _agent_state_lock:
381
- _agent_state["interrupt"] = interrupt_data
382
- _agent_state["running"] = False # Pause until user responds
383
- _agent_state["last_update"] = time.time()
431
+ with state_lock:
432
+ current_state["interrupt"] = interrupt_data
433
+ current_state["running"] = False # Pause until user responds
434
+ current_state["last_update"] = time.time()
384
435
  return # Exit stream, wait for user to resume
385
436
 
386
437
  if isinstance(update, dict):
@@ -399,9 +450,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
399
450
  tool_call_map[serialized["id"]] = serialized
400
451
  new_tool_calls.append(serialized)
401
452
 
402
- with _agent_state_lock:
403
- _agent_state["tool_calls"].extend(new_tool_calls)
404
- _agent_state["last_update"] = time.time()
453
+ with state_lock:
454
+ current_state["tool_calls"].extend(new_tool_calls)
455
+ current_state["last_update"] = time.time()
405
456
 
406
457
  elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
407
458
  # Update tool call status when we get the result
@@ -450,9 +501,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
450
501
  thinking_text = content.get('reflection', str(content))
451
502
 
452
503
  # Update state immediately
453
- with _agent_state_lock:
454
- _agent_state["thinking"] = thinking_text
455
- _agent_state["last_update"] = time.time()
504
+ with state_lock:
505
+ current_state["thinking"] = thinking_text
506
+ current_state["last_update"] = time.time()
456
507
 
457
508
  elif last_msg.name == 'write_todos':
458
509
  content = last_msg.content
@@ -472,9 +523,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
472
523
  todos = content
473
524
 
474
525
  # Update state immediately
475
- with _agent_state_lock:
476
- _agent_state["todos"] = todos
477
- _agent_state["last_update"] = time.time()
526
+ with state_lock:
527
+ current_state["todos"] = todos
528
+ current_state["last_update"] = time.time()
478
529
 
479
530
  elif last_msg.name == 'add_to_canvas':
480
531
  content = last_msg.content
@@ -492,15 +543,16 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
492
543
  canvas_item = {"type": "markdown", "data": str(content)}
493
544
 
494
545
  # Update state immediately - append to canvas
495
- with _agent_state_lock:
496
- _agent_state["canvas"].append(canvas_item)
497
- _agent_state["last_update"] = time.time()
546
+ with state_lock:
547
+ current_state["canvas"].append(canvas_item)
548
+ current_state["last_update"] = time.time()
498
549
 
499
- # Also export to markdown file
500
- try:
501
- export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
502
- except Exception as e:
503
- print(f"Failed to export canvas: {e}")
550
+ # Also export to markdown file (physical FS only)
551
+ if not USE_VIRTUAL_FS:
552
+ try:
553
+ export_canvas_to_markdown(current_state["canvas"], WORKSPACE_ROOT)
554
+ except Exception as e:
555
+ print(f"Failed to export canvas: {e}")
504
556
 
505
557
  elif last_msg.name == 'update_canvas_item':
506
558
  content = last_msg.content
@@ -517,22 +569,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
517
569
 
518
570
  item_id = canvas_item.get("id")
519
571
  if item_id:
520
- with _agent_state_lock:
572
+ with state_lock:
521
573
  # Find and replace the item with matching ID
522
- for i, existing in enumerate(_agent_state["canvas"]):
574
+ for i, existing in enumerate(current_state["canvas"]):
523
575
  if existing.get("id") == item_id:
524
- _agent_state["canvas"][i] = canvas_item
576
+ current_state["canvas"][i] = canvas_item
525
577
  break
526
578
  else:
527
579
  # If not found, append as new item
528
- _agent_state["canvas"].append(canvas_item)
529
- _agent_state["last_update"] = time.time()
580
+ current_state["canvas"].append(canvas_item)
581
+ current_state["last_update"] = time.time()
530
582
 
531
- # Export to markdown file
532
- try:
533
- export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
534
- except Exception as e:
535
- print(f"Failed to export canvas: {e}")
583
+ # Export to markdown file (physical FS only)
584
+ if not USE_VIRTUAL_FS:
585
+ try:
586
+ export_canvas_to_markdown(current_state["canvas"], WORKSPACE_ROOT)
587
+ except Exception as e:
588
+ print(f"Failed to export canvas: {e}")
536
589
 
537
590
  elif last_msg.name == 'remove_canvas_item':
538
591
  content = last_msg.content
@@ -549,18 +602,19 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
549
602
  item_id = None
550
603
 
551
604
  if item_id:
552
- with _agent_state_lock:
553
- _agent_state["canvas"] = [
554
- item for item in _agent_state["canvas"]
605
+ with state_lock:
606
+ current_state["canvas"] = [
607
+ item for item in current_state["canvas"]
555
608
  if item.get("id") != item_id
556
609
  ]
557
- _agent_state["last_update"] = time.time()
610
+ current_state["last_update"] = time.time()
558
611
 
559
- # Export to markdown file
560
- try:
561
- export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
562
- except Exception as e:
563
- print(f"Failed to export canvas: {e}")
612
+ # Export to markdown file (physical FS only)
613
+ if not USE_VIRTUAL_FS:
614
+ try:
615
+ export_canvas_to_markdown(current_state["canvas"], WORKSPACE_ROOT)
616
+ except Exception as e:
617
+ print(f"Failed to export canvas: {e}")
564
618
 
565
619
  elif last_msg.name in ('execute_cell', 'execute_all_cells'):
566
620
  # Extract canvas_items from cell execution results
@@ -589,17 +643,18 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
589
643
 
590
644
  # Add any canvas items found
591
645
  if canvas_items_to_add:
592
- with _agent_state_lock:
646
+ with state_lock:
593
647
  for item in canvas_items_to_add:
594
648
  if isinstance(item, dict) and item.get('type'):
595
- _agent_state["canvas"].append(item)
596
- _agent_state["last_update"] = time.time()
649
+ current_state["canvas"].append(item)
650
+ current_state["last_update"] = time.time()
597
651
 
598
- # Export to markdown file
599
- try:
600
- export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
601
- except Exception as e:
602
- print(f"Failed to export canvas: {e}")
652
+ # Export to markdown file (physical FS only)
653
+ if not USE_VIRTUAL_FS:
654
+ try:
655
+ export_canvas_to_markdown(current_state["canvas"], WORKSPACE_ROOT)
656
+ except Exception as e:
657
+ print(f"Failed to export canvas: {e}")
603
658
 
604
659
  elif hasattr(last_msg, 'content'):
605
660
  content = last_msg.content
@@ -617,19 +672,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
617
672
  response_text = " ".join(text_parts).strip()
618
673
 
619
674
  if response_text:
620
- with _agent_state_lock:
621
- _agent_state["response"] = response_text
622
- _agent_state["last_update"] = time.time()
675
+ with state_lock:
676
+ current_state["response"] = response_text
677
+ current_state["last_update"] = time.time()
623
678
 
624
679
  except Exception as e:
625
- with _agent_state_lock:
626
- _agent_state["error"] = str(e)
627
- _agent_state["response"] = f"Error: {str(e)}"
680
+ with state_lock:
681
+ current_state["error"] = str(e)
682
+ current_state["response"] = f"Error: {str(e)}"
628
683
 
629
684
  finally:
630
- with _agent_state_lock:
631
- _agent_state["running"] = False
632
- _agent_state["last_update"] = time.time()
685
+ # Clear tool session context
686
+ if USE_VIRTUAL_FS and session_id:
687
+ clear_tool_session_context()
688
+
689
+ with state_lock:
690
+ current_state["running"] = False
691
+ current_state["last_update"] = time.time()
633
692
 
634
693
 
635
694
  def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
@@ -728,20 +787,29 @@ def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
728
787
 
729
788
  return interrupt_data
730
789
 
731
- def call_agent(message: str, resume_data: Dict = None, workspace_path: str = None):
790
+ def call_agent(message: str, resume_data: Dict = None, workspace_path: str = None, session_id: Optional[str] = None):
732
791
  """Start agent execution in background thread.
733
792
 
734
793
  Args:
735
794
  message: User message to send to agent
736
795
  resume_data: Optional dict with decisions to resume from interrupt
737
796
  workspace_path: Current workspace directory path to inject into agent context
797
+ session_id: Session ID for virtual FS mode
738
798
  """
799
+ # Determine which state to use
800
+ if USE_VIRTUAL_FS and session_id:
801
+ current_state = _get_session_state(session_id)
802
+ state_lock = _session_agents_lock
803
+ else:
804
+ current_state = _agent_state
805
+ state_lock = _agent_state_lock
806
+
739
807
  # Reset state but preserve canvas - do it all atomically
740
- with _agent_state_lock:
741
- existing_canvas = _agent_state.get("canvas", []).copy()
808
+ with state_lock:
809
+ existing_canvas = current_state.get("canvas", []).copy()
742
810
 
743
- _agent_state.clear()
744
- _agent_state.update({
811
+ current_state.clear()
812
+ current_state.update({
745
813
  "running": True,
746
814
  "thinking": "",
747
815
  "todos": [],
@@ -756,21 +824,30 @@ def call_agent(message: str, resume_data: Dict = None, workspace_path: str = Non
756
824
  })
757
825
 
758
826
  # Start background thread
759
- thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data, workspace_path))
827
+ thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data, workspace_path, session_id))
760
828
  thread.daemon = True
761
829
  thread.start()
762
830
 
763
831
 
764
- def resume_agent_from_interrupt(decision: str, action: str = "approve", action_requests: List[Dict] = None):
832
+ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_requests: List[Dict] = None, session_id: Optional[str] = None):
765
833
  """Resume agent from an interrupt with the user's decision.
766
834
 
767
835
  Args:
768
836
  decision: User's response/decision text
769
837
  action: One of 'approve', 'reject', 'edit'
770
838
  action_requests: List of action requests from the interrupt (for edit mode)
839
+ session_id: Session ID for virtual FS mode
771
840
  """
772
- with _agent_state_lock:
773
- interrupt_data = _agent_state.get("interrupt")
841
+ # Determine which state to use
842
+ if USE_VIRTUAL_FS and session_id:
843
+ current_state = _get_session_state(session_id)
844
+ state_lock = _session_agents_lock
845
+ else:
846
+ current_state = _agent_state
847
+ state_lock = _agent_state_lock
848
+
849
+ with state_lock:
850
+ interrupt_data = current_state.get("interrupt")
774
851
  if not interrupt_data:
775
852
  return
776
853
 
@@ -779,16 +856,16 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
779
856
  action_requests = interrupt_data.get("action_requests", [])
780
857
 
781
858
  # Clear interrupt and set running, but preserve tool_calls and canvas
782
- existing_tool_calls = _agent_state.get("tool_calls", []).copy()
783
- existing_canvas = _agent_state.get("canvas", []).copy()
859
+ existing_tool_calls = current_state.get("tool_calls", []).copy()
860
+ existing_canvas = current_state.get("canvas", []).copy()
784
861
 
785
- _agent_state["interrupt"] = None
786
- _agent_state["running"] = True
787
- _agent_state["response"] = "" # Clear any previous response
788
- _agent_state["error"] = None # Clear any previous error
789
- _agent_state["tool_calls"] = existing_tool_calls # Keep existing tool calls
790
- _agent_state["canvas"] = existing_canvas # Keep canvas
791
- _agent_state["last_update"] = time.time()
862
+ current_state["interrupt"] = None
863
+ current_state["running"] = True
864
+ current_state["response"] = "" # Clear any previous response
865
+ current_state["error"] = None # Clear any previous error
866
+ current_state["tool_calls"] = existing_tool_calls # Keep existing tool calls
867
+ current_state["canvas"] = existing_canvas # Keep canvas
868
+ current_state["last_update"] = time.time()
792
869
 
793
870
  # Build decisions list in the format expected by deepagents HITL middleware
794
871
  # Format: {"decisions": [{"type": "approve"}, {"type": "reject", "message": "..."}, ...]}
@@ -812,10 +889,10 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
812
889
  tool_names = [ar.get("tool", "unknown") for ar in action_requests]
813
890
  tool_info = f" ({', '.join(tool_names)})"
814
891
 
815
- with _agent_state_lock:
816
- _agent_state["running"] = False
817
- _agent_state["response"] = f"Action rejected{tool_info}: {reject_message}"
818
- _agent_state["last_update"] = time.time()
892
+ with state_lock:
893
+ current_state["running"] = False
894
+ current_state["response"] = f"Action rejected{tool_info}: {reject_message}"
895
+ current_state["last_update"] = time.time()
819
896
 
820
897
  return # Don't resume the agent
821
898
  else: # edit - provide edited action
@@ -845,14 +922,62 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
845
922
 
846
923
  # Start background thread with resume value
847
924
  # Pass a special marker to indicate this is a resume operation
848
- thread = threading.Thread(target=_run_agent_stream, args=("__RESUME__", resume_value))
925
+ thread = threading.Thread(target=_run_agent_stream, args=("__RESUME__", resume_value, None, session_id))
849
926
  thread.daemon = True
850
927
  thread.start()
851
928
 
852
- def get_agent_state() -> Dict[str, Any]:
853
- """Get current agent state (thread-safe)."""
854
- with _agent_state_lock:
855
- return _agent_state.copy()
929
+ def get_agent_state(session_id: Optional[str] = None) -> Dict[str, Any]:
930
+ """Get current agent state (thread-safe).
931
+
932
+ Args:
933
+ session_id: Session ID for virtual FS mode, None for physical FS mode.
934
+
935
+ Returns a deep copy of mutable collections to prevent race conditions.
936
+ """
937
+ if USE_VIRTUAL_FS and session_id:
938
+ current_state = _get_session_state(session_id)
939
+ state_lock = _session_agents_lock
940
+ else:
941
+ current_state = _agent_state
942
+ state_lock = _agent_state_lock
943
+
944
+ with state_lock:
945
+ state = current_state.copy()
946
+ # Deep copy mutable collections to prevent race conditions during rendering
947
+ state["tool_calls"] = copy.deepcopy(current_state["tool_calls"])
948
+ state["todos"] = copy.deepcopy(current_state["todos"])
949
+ state["canvas"] = copy.deepcopy(current_state["canvas"])
950
+ return state
951
+
952
+
953
+ def reset_agent_state(session_id: Optional[str] = None):
954
+ """Reset agent state for a fresh session (thread-safe).
955
+
956
+ Called on page load to ensure clean state after browser refresh.
957
+ Preserves canvas items loaded from canvas.md (physical FS only).
958
+
959
+ Args:
960
+ session_id: Session ID for virtual FS mode, None for physical FS mode.
961
+ """
962
+ if USE_VIRTUAL_FS and session_id:
963
+ current_state = _get_session_state(session_id)
964
+ state_lock = _session_agents_lock
965
+ else:
966
+ current_state = _agent_state
967
+ state_lock = _agent_state_lock
968
+
969
+ with state_lock:
970
+ current_state["running"] = False
971
+ current_state["thinking"] = ""
972
+ current_state["todos"] = []
973
+ current_state["tool_calls"] = []
974
+ current_state["response"] = ""
975
+ current_state["error"] = None
976
+ current_state["interrupt"] = None
977
+ current_state["start_time"] = None
978
+ current_state["stop_requested"] = False
979
+ current_state["last_update"] = time.time()
980
+ # Note: canvas is preserved - it's loaded from canvas.md on startup (physical FS only)
856
981
 
857
982
  # =============================================================================
858
983
  # DASH APP
@@ -899,8 +1024,12 @@ def create_layout():
899
1024
  title = getattr(agent, 'name', None) or APP_TITLE
900
1025
  subtitle = getattr(agent, 'description', None) or APP_SUBTITLE
901
1026
 
1027
+ # In virtual FS mode, pass None for workspace_root to show empty tree initially
1028
+ # The file tree will be populated per-session via callbacks
1029
+ workspace_for_layout = None if USE_VIRTUAL_FS else WORKSPACE_ROOT
1030
+
902
1031
  return create_layout_component(
903
- workspace_root=WORKSPACE_ROOT,
1032
+ workspace_root=workspace_for_layout,
904
1033
  app_title=title,
905
1034
  app_subtitle=subtitle,
906
1035
  colors=COLORS,
@@ -921,15 +1050,37 @@ app.layout = create_layout
921
1050
 
922
1051
  # Initial message display
923
1052
  @app.callback(
924
- Output("chat-messages", "children"),
1053
+ [Output("chat-messages", "children"),
1054
+ Output("skip-history-render", "data", allow_duplicate=True),
1055
+ Output("session-initialized", "data", allow_duplicate=True),
1056
+ Output("session-id", "data", allow_duplicate=True)],
925
1057
  [Input("chat-history", "data")],
926
- [State("theme-store", "data")],
927
- prevent_initial_call=False
1058
+ [State("theme-store", "data"),
1059
+ State("skip-history-render", "data"),
1060
+ State("session-initialized", "data"),
1061
+ State("session-id", "data")],
1062
+ prevent_initial_call='initial_duplicate'
928
1063
  )
929
- def display_initial_messages(history, theme):
930
- """Display initial welcome message or chat history."""
1064
+ def display_initial_messages(history, theme, skip_render, session_initialized, session_id):
1065
+ """Display initial welcome message or chat history.
1066
+
1067
+ On first call (page load), resets agent state for a fresh session.
1068
+ Skip rendering if skip_render flag is set - this prevents duplicate renders
1069
+ when poll_agent_updates already handles the rendering.
1070
+ """
1071
+ # Initialize session on page load (first callback trigger)
1072
+ new_session_id = session_id
1073
+ if not session_initialized:
1074
+ # Create or validate session ID for virtual FS mode
1075
+ new_session_id = get_or_create_session_id(session_id)
1076
+ reset_agent_state(new_session_id)
1077
+
1078
+ # Skip if flag is set (poll_agent_updates already rendered)
1079
+ if skip_render:
1080
+ return no_update, False, True, new_session_id # Reset skip flag, mark session initialized
1081
+
931
1082
  if not history:
932
- return []
1083
+ return [], False, True, new_session_id
933
1084
 
934
1085
  colors = get_colors(theme or "light")
935
1086
  messages = []
@@ -946,7 +1097,41 @@ def display_initial_messages(history, theme):
946
1097
  todos_block = format_todos_inline(msg["todos"], colors)
947
1098
  if todos_block:
948
1099
  messages.append(todos_block)
949
- return messages
1100
+ return messages, False, True, new_session_id
1101
+
1102
+
1103
+ # Initialize file tree for virtual FS sessions
1104
+ @app.callback(
1105
+ Output("file-tree", "children", allow_duplicate=True),
1106
+ Input("session-initialized", "data"),
1107
+ [State("session-id", "data"),
1108
+ State("current-workspace-path", "data"),
1109
+ State("theme-store", "data")],
1110
+ prevent_initial_call=True
1111
+ )
1112
+ def initialize_file_tree_for_session(session_initialized, session_id, current_workspace, theme):
1113
+ """Initialize file tree when a new session is created (virtual FS mode).
1114
+
1115
+ In virtual FS mode, the file tree starts empty. This callback populates it
1116
+ when the session is initialized, showing the default workspace structure.
1117
+ """
1118
+ if not USE_VIRTUAL_FS:
1119
+ raise PreventUpdate
1120
+
1121
+ if not session_initialized or not session_id:
1122
+ raise PreventUpdate
1123
+
1124
+ colors = get_colors(theme or "light")
1125
+
1126
+ # Get workspace for this session
1127
+ workspace_root = get_workspace_for_session(session_id)
1128
+
1129
+ # Calculate current workspace directory
1130
+ current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
1131
+
1132
+ # Build and render file tree
1133
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
1134
+
950
1135
 
951
1136
  # Chat callbacks
952
1137
  @app.callback(
@@ -960,10 +1145,11 @@ def display_initial_messages(history, theme):
960
1145
  [State("chat-input", "value"),
961
1146
  State("chat-history", "data"),
962
1147
  State("theme-store", "data"),
963
- State("current-workspace-path", "data")],
1148
+ State("current-workspace-path", "data"),
1149
+ State("session-id", "data")],
964
1150
  prevent_initial_call=True
965
1151
  )
966
- def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_workspace_path):
1152
+ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_workspace_path, session_id):
967
1153
  """Phase 1: Immediately show user message and start agent."""
968
1154
  if not message or not message.strip():
969
1155
  raise PreventUpdate
@@ -992,11 +1178,19 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
992
1178
 
993
1179
  messages.append(format_loading(colors))
994
1180
 
995
- # Calculate full workspace path for agent context
996
- workspace_full_path = WORKSPACE_ROOT / current_workspace_path if current_workspace_path else WORKSPACE_ROOT
1181
+ # Calculate workspace path for agent context
1182
+ # In virtual FS mode, use virtual paths (e.g., /workspace/subdir)
1183
+ # In physical FS mode, use actual filesystem paths
1184
+ if USE_VIRTUAL_FS:
1185
+ # Virtual FS mode: use the virtual path directly
1186
+ # The VirtualFilesystem root is /workspace, so paths are like /workspace or /workspace/subdir
1187
+ workspace_full_path = f"/workspace/{current_workspace_path}" if current_workspace_path else "/workspace"
1188
+ else:
1189
+ # Physical FS mode: use actual filesystem path
1190
+ workspace_full_path = str(WORKSPACE_ROOT / current_workspace_path if current_workspace_path else WORKSPACE_ROOT)
997
1191
 
998
1192
  # Start agent in background with workspace context
999
- call_agent(message, workspace_path=str(workspace_full_path))
1193
+ call_agent(message, workspace_path=workspace_full_path, session_id=session_id)
1000
1194
 
1001
1195
  # Enable polling
1002
1196
  return messages, history, "", message, False
@@ -1005,14 +1199,16 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
1005
1199
  @app.callback(
1006
1200
  [Output("chat-messages", "children", allow_duplicate=True),
1007
1201
  Output("chat-history", "data", allow_duplicate=True),
1008
- Output("poll-interval", "disabled", allow_duplicate=True)],
1202
+ Output("poll-interval", "disabled", allow_duplicate=True),
1203
+ Output("skip-history-render", "data", allow_duplicate=True)],
1009
1204
  Input("poll-interval", "n_intervals"),
1010
1205
  [State("chat-history", "data"),
1011
1206
  State("pending-message", "data"),
1012
- State("theme-store", "data")],
1207
+ State("theme-store", "data"),
1208
+ State("session-id", "data")],
1013
1209
  prevent_initial_call=True
1014
1210
  )
1015
- def poll_agent_updates(n_intervals, history, pending_message, theme):
1211
+ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id):
1016
1212
  """Poll for agent updates and display them in real-time.
1017
1213
 
1018
1214
  Tool calls are stored in history and persist across turns.
@@ -1020,7 +1216,7 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
1020
1216
  - {"role": "user", "content": "..."} - user message
1021
1217
  - {"role": "assistant", "content": "...", "tool_calls": [...]} - assistant message with tool calls
1022
1218
  """
1023
- state = get_agent_state()
1219
+ state = get_agent_state(session_id)
1024
1220
  history = history or []
1025
1221
  colors = get_colors(theme or "light")
1026
1222
 
@@ -1069,7 +1265,7 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
1069
1265
  messages.append(interrupt_block)
1070
1266
 
1071
1267
  # Disable polling - wait for user to respond to interrupt
1072
- return messages, no_update, True
1268
+ return messages, no_update, True, no_update
1073
1269
 
1074
1270
  # Check if agent is done
1075
1271
  if not state["running"]:
@@ -1115,8 +1311,8 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
1115
1311
  if todos_block:
1116
1312
  final_messages.append(todos_block)
1117
1313
 
1118
- # Disable polling
1119
- return final_messages, history, True
1314
+ # Disable polling, set skip flag to prevent display_initial_messages from re-rendering
1315
+ return final_messages, history, True, True
1120
1316
  else:
1121
1317
  # Agent still running - show loading with current thinking/tool_calls/todos
1122
1318
  messages = render_history_messages(history)
@@ -1142,19 +1338,20 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
1142
1338
  # Add loading indicator
1143
1339
  messages.append(format_loading(colors))
1144
1340
 
1145
- # Continue polling
1146
- return messages, no_update, False
1341
+ # Continue polling, no skip flag needed
1342
+ return messages, no_update, False, no_update
1147
1343
 
1148
1344
 
1149
1345
  # Stop button visibility - show when agent is running
1150
1346
  @app.callback(
1151
1347
  Output("stop-btn", "style"),
1152
1348
  Input("poll-interval", "n_intervals"),
1349
+ State("session-id", "data"),
1153
1350
  prevent_initial_call=True
1154
1351
  )
1155
- def update_stop_button_visibility(n_intervals):
1352
+ def update_stop_button_visibility(n_intervals, session_id):
1156
1353
  """Show stop button when agent is running, hide otherwise."""
1157
- state = get_agent_state()
1354
+ state = get_agent_state(session_id)
1158
1355
  if state.get("running"):
1159
1356
  return {} # Show button (remove display:none)
1160
1357
  else:
@@ -1167,10 +1364,11 @@ def update_stop_button_visibility(n_intervals):
1167
1364
  Output("poll-interval", "disabled", allow_duplicate=True)],
1168
1365
  Input("stop-btn", "n_clicks"),
1169
1366
  [State("chat-history", "data"),
1170
- State("theme-store", "data")],
1367
+ State("theme-store", "data"),
1368
+ State("session-id", "data")],
1171
1369
  prevent_initial_call=True
1172
1370
  )
1173
- def handle_stop_button(n_clicks, history, theme):
1371
+ def handle_stop_button(n_clicks, history, theme, session_id):
1174
1372
  """Handle stop button click to stop agent execution."""
1175
1373
  if not n_clicks:
1176
1374
  raise PreventUpdate
@@ -1179,7 +1377,7 @@ def handle_stop_button(n_clicks, history, theme):
1179
1377
  history = history or []
1180
1378
 
1181
1379
  # Request the agent to stop
1182
- request_agent_stop()
1380
+ request_agent_stop(session_id)
1183
1381
 
1184
1382
  # Render current messages with a stopping indicator
1185
1383
  def render_history_messages(history):
@@ -1221,10 +1419,11 @@ def handle_stop_button(n_clicks, history, theme):
1221
1419
  Input("interrupt-edit-btn", "n_clicks")],
1222
1420
  [State("interrupt-input", "value"),
1223
1421
  State("chat-history", "data"),
1224
- State("theme-store", "data")],
1422
+ State("theme-store", "data"),
1423
+ State("session-id", "data")],
1225
1424
  prevent_initial_call=True
1226
1425
  )
1227
- def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_value, history, theme):
1426
+ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_value, history, theme, session_id):
1228
1427
  """Handle user response to an interrupt.
1229
1428
 
1230
1429
  Note: Click parameters are required for Dash callback inputs but we use
@@ -1266,7 +1465,7 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1266
1465
  raise PreventUpdate
1267
1466
 
1268
1467
  # Resume the agent with the user's decision
1269
- resume_agent_from_interrupt(decision, action)
1468
+ resume_agent_from_interrupt(decision, action, session_id=session_id)
1270
1469
 
1271
1470
  # Show loading state while agent resumes
1272
1471
  messages = []
@@ -1303,16 +1502,20 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1303
1502
  State({"type": "folder-children", "path": ALL}, "style"),
1304
1503
  State({"type": "folder-icon", "path": ALL}, "style"),
1305
1504
  State({"type": "folder-children", "path": ALL}, "children"),
1306
- State("theme-store", "data")],
1505
+ State("theme-store", "data"),
1506
+ State("session-id", "data")],
1307
1507
  prevent_initial_call=True
1308
1508
  )
1309
- def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme):
1509
+ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme, session_id):
1310
1510
  """Toggle folder expansion and lazy load contents if needed."""
1311
1511
  ctx = callback_context
1312
1512
  if not ctx.triggered or not any(n_clicks):
1313
1513
  raise PreventUpdate
1314
1514
 
1315
1515
  colors = get_colors(theme or "light")
1516
+
1517
+ # Get workspace for this session (virtual or physical)
1518
+ workspace_root = get_workspace_for_session(session_id)
1316
1519
  triggered = ctx.triggered[0]["prop_id"]
1317
1520
  try:
1318
1521
  id_str = triggered.rsplit(".", 1)[0]
@@ -1354,7 +1557,7 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1354
1557
  current_content[0].get("props", {}).get("children") == "Loading..."):
1355
1558
  # Load folder contents using real path
1356
1559
  try:
1357
- folder_items = load_folder_contents(folder_rel_path, WORKSPACE_ROOT)
1560
+ folder_items = load_folder_contents(folder_rel_path, workspace_root)
1358
1561
  loaded_content = render_file_tree(folder_items, colors, STYLES,
1359
1562
  level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
1360
1563
  parent_path=folder_rel_path)
@@ -1409,10 +1612,11 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1409
1612
  State({"type": "folder-select", "path": ALL}, "data-folderpath"),
1410
1613
  State({"type": "folder-select", "path": ALL}, "n_clicks"),
1411
1614
  State("current-workspace-path", "data"),
1412
- State("theme-store", "data")],
1615
+ State("theme-store", "data"),
1616
+ State("session-id", "data")],
1413
1617
  prevent_initial_call=True
1414
1618
  )
1415
- def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, folder_paths, prev_clicks, current_path, theme):
1619
+ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, folder_paths, _prev_clicks, current_path, theme, session_id):
1416
1620
  """Enter a folder (double-click) or navigate via breadcrumb."""
1417
1621
  ctx = callback_context
1418
1622
  if not ctx.triggered:
@@ -1453,7 +1657,6 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
1453
1657
  for i, folder_id in enumerate(folder_ids):
1454
1658
  if folder_id["path"] == clicked_path:
1455
1659
  current_clicks = folder_clicks[i] if i < len(folder_clicks) else 0
1456
- previous_clicks = prev_clicks[i] if i < len(prev_clicks) else 0
1457
1660
 
1458
1661
  # Only enter on double-click (clicks increased and is even number >= 2)
1459
1662
  if current_clicks and current_clicks >= 2 and current_clicks % 2 == 0:
@@ -1508,8 +1711,14 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
1508
1711
  )
1509
1712
  )
1510
1713
 
1714
+ # Get workspace for this session (virtual or physical)
1715
+ workspace_root = get_workspace_for_session(session_id)
1716
+
1511
1717
  # Calculate the actual workspace path
1512
- workspace_full_path = WORKSPACE_ROOT / new_path if new_path else WORKSPACE_ROOT
1718
+ if USE_VIRTUAL_FS:
1719
+ workspace_full_path = workspace_root.path(new_path) if new_path else workspace_root.root
1720
+ else:
1721
+ workspace_full_path = workspace_root / new_path if new_path else workspace_root
1513
1722
 
1514
1723
  # Render new file tree
1515
1724
  file_tree = render_file_tree(
@@ -1530,10 +1739,11 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
1530
1739
  Input({"type": "file-item", "path": ALL}, "n_clicks"),
1531
1740
  [State({"type": "file-item", "path": ALL}, "id"),
1532
1741
  State("file-click-tracker", "data"),
1533
- State("theme-store", "data")],
1742
+ State("theme-store", "data"),
1743
+ State("session-id", "data")],
1534
1744
  prevent_initial_call=True
1535
1745
  )
1536
- def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
1746
+ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme, session_id):
1537
1747
  """Open file in modal - only on actual new clicks."""
1538
1748
  ctx = callback_context
1539
1749
 
@@ -1581,13 +1791,19 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
1581
1791
  # Still need to return updated tracker to avoid stale state
1582
1792
  raise PreventUpdate
1583
1793
 
1794
+ # Get workspace for this session (virtual or physical)
1795
+ workspace_root = get_workspace_for_session(session_id)
1796
+
1584
1797
  # Verify file exists and is a file
1585
- full_path = WORKSPACE_ROOT / file_path
1798
+ if USE_VIRTUAL_FS:
1799
+ full_path = workspace_root.path(file_path)
1800
+ else:
1801
+ full_path = workspace_root / file_path
1586
1802
  if not full_path.exists() or not full_path.is_file():
1587
1803
  raise PreventUpdate
1588
1804
 
1589
1805
  colors = get_colors(theme or "light")
1590
- content, is_text, error = read_file_content(WORKSPACE_ROOT, file_path)
1806
+ content, is_text, error = read_file_content(workspace_root, file_path)
1591
1807
  filename = Path(file_path).name
1592
1808
 
1593
1809
  if is_text and content:
@@ -1626,10 +1842,11 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
1626
1842
  @app.callback(
1627
1843
  Output("file-download", "data", allow_duplicate=True),
1628
1844
  Input("modal-download-btn", "n_clicks"),
1629
- State("file-to-view", "data"),
1845
+ [State("file-to-view", "data"),
1846
+ State("session-id", "data")],
1630
1847
  prevent_initial_call=True
1631
1848
  )
1632
- def download_from_modal(n_clicks, file_path):
1849
+ def download_from_modal(n_clicks, file_path, session_id):
1633
1850
  """Download file from modal."""
1634
1851
  ctx = callback_context
1635
1852
  if not ctx.triggered:
@@ -1643,7 +1860,10 @@ def download_from_modal(n_clicks, file_path):
1643
1860
  if not n_clicks or not file_path:
1644
1861
  raise PreventUpdate
1645
1862
 
1646
- b64, filename, mime = get_file_download_data(WORKSPACE_ROOT, file_path)
1863
+ # Get workspace for this session (virtual or physical)
1864
+ workspace_root = get_workspace_for_session(session_id)
1865
+
1866
+ b64, filename, mime = get_file_download_data(workspace_root, file_path)
1647
1867
  if not b64:
1648
1868
  raise PreventUpdate
1649
1869
 
@@ -1660,10 +1880,15 @@ def open_terminal(n_clicks):
1660
1880
  """Open system terminal at workspace directory."""
1661
1881
  if not n_clicks:
1662
1882
  raise PreventUpdate
1663
-
1883
+
1884
+ # Terminal doesn't work with virtual filesystem (no physical path)
1885
+ if USE_VIRTUAL_FS:
1886
+ print("Terminal not available in virtual filesystem mode")
1887
+ raise PreventUpdate
1888
+
1664
1889
  workspace_path = str(WORKSPACE_ROOT)
1665
1890
  system = platform.system()
1666
-
1891
+
1667
1892
  try:
1668
1893
  if system == "Darwin": # macOS
1669
1894
  subprocess.Popen(["open", "-a", "Terminal", workspace_path])
@@ -1685,7 +1910,7 @@ def open_terminal(n_clicks):
1685
1910
  continue
1686
1911
  except Exception as e:
1687
1912
  print(f"Failed to open terminal: {e}")
1688
-
1913
+
1689
1914
  raise PreventUpdate
1690
1915
 
1691
1916
 
@@ -1696,27 +1921,31 @@ def open_terminal(n_clicks):
1696
1921
  Input("refresh-btn", "n_clicks"),
1697
1922
  [State("current-workspace-path", "data"),
1698
1923
  State("theme-store", "data"),
1699
- State("collapsed-canvas-items", "data")],
1924
+ State("collapsed-canvas-items", "data"),
1925
+ State("session-id", "data")],
1700
1926
  prevent_initial_call=True
1701
1927
  )
1702
- def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
1928
+ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_id):
1703
1929
  """Refresh both file tree and canvas content."""
1704
- global _agent_state
1705
1930
  colors = get_colors(theme or "light")
1706
1931
  collapsed_ids = collapsed_ids or []
1707
1932
 
1933
+ # Get workspace for this session (virtual or physical)
1934
+ workspace_root = get_workspace_for_session(session_id)
1935
+
1708
1936
  # Calculate current workspace directory
1709
- current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
1937
+ if USE_VIRTUAL_FS:
1938
+ current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
1939
+ else:
1940
+ current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
1710
1941
 
1711
1942
  # Refresh file tree for current workspace
1712
1943
  file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
1713
1944
 
1714
- # Refresh canvas by reloading from .canvas/canvas.md file (always from original root)
1715
- canvas_items = load_canvas_from_markdown(WORKSPACE_ROOT)
1716
-
1717
- # Update agent state with reloaded canvas
1718
- with _agent_state_lock:
1719
- _agent_state["canvas"] = canvas_items
1945
+ # Re-render canvas from current in-memory state (don't reload from file)
1946
+ # This preserves canvas items that may not have been exported to .canvas/canvas.md yet
1947
+ state = get_agent_state(session_id)
1948
+ canvas_items = state.get("canvas", [])
1720
1949
 
1721
1950
  # Render the canvas items with preserved collapsed state
1722
1951
  canvas_content = render_canvas_items(canvas_items, colors, collapsed_ids)
@@ -1730,17 +1959,25 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
1730
1959
  Input("file-upload-sidebar", "contents"),
1731
1960
  [State("file-upload-sidebar", "filename"),
1732
1961
  State("current-workspace-path", "data"),
1733
- State("theme-store", "data")],
1962
+ State("theme-store", "data"),
1963
+ State("session-id", "data")],
1734
1964
  prevent_initial_call=True
1735
1965
  )
1736
- def handle_sidebar_upload(contents, filenames, current_workspace, theme):
1966
+ def handle_sidebar_upload(contents, filenames, current_workspace, theme, session_id):
1737
1967
  """Handle file uploads from sidebar button to current workspace."""
1738
1968
  if not contents:
1739
1969
  raise PreventUpdate
1740
1970
 
1741
1971
  colors = get_colors(theme or "light")
1972
+
1973
+ # Get workspace for this session (virtual or physical)
1974
+ workspace_root = get_workspace_for_session(session_id)
1975
+
1742
1976
  # Calculate current workspace directory
1743
- current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
1977
+ if USE_VIRTUAL_FS:
1978
+ current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
1979
+ else:
1980
+ current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
1744
1981
 
1745
1982
  for content, filename in zip(contents, filenames):
1746
1983
  try:
@@ -1796,10 +2033,11 @@ def toggle_create_folder_modal(open_clicks, cancel_clicks, confirm_clicks, is_op
1796
2033
  Input("confirm-folder-btn", "n_clicks"),
1797
2034
  [State("new-folder-name", "value"),
1798
2035
  State("current-workspace-path", "data"),
1799
- State("theme-store", "data")],
2036
+ State("theme-store", "data"),
2037
+ State("session-id", "data")],
1800
2038
  prevent_initial_call=True
1801
2039
  )
1802
- def create_folder(n_clicks, folder_name, current_workspace, theme):
2040
+ def create_folder(n_clicks, folder_name, current_workspace, theme, session_id):
1803
2041
  """Create a new folder in the current workspace directory."""
1804
2042
  if not n_clicks:
1805
2043
  raise PreventUpdate
@@ -1816,8 +2054,15 @@ def create_folder(n_clicks, folder_name, current_workspace, theme):
1816
2054
  if any(char in folder_name for char in invalid_chars):
1817
2055
  return no_update, f"Folder name cannot contain: {' '.join(invalid_chars)}", no_update
1818
2056
 
2057
+ # Get workspace for this session (virtual or physical)
2058
+ workspace_root = get_workspace_for_session(session_id)
2059
+
1819
2060
  # Calculate current workspace directory
1820
- current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
2061
+ if USE_VIRTUAL_FS:
2062
+ current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
2063
+ else:
2064
+ current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
2065
+
1821
2066
  folder_path = current_workspace_dir / folder_name
1822
2067
 
1823
2068
  if folder_path.exists():
@@ -1889,12 +2134,13 @@ def toggle_view(view_value):
1889
2134
  [Input("poll-interval", "n_intervals"),
1890
2135
  Input("sidebar-view-toggle", "value")],
1891
2136
  [State("theme-store", "data"),
1892
- State("collapsed-canvas-items", "data")],
2137
+ State("collapsed-canvas-items", "data"),
2138
+ State("session-id", "data")],
1893
2139
  prevent_initial_call=False
1894
2140
  )
1895
- def update_canvas_content(n_intervals, view_value, theme, collapsed_ids):
2141
+ def update_canvas_content(n_intervals, view_value, theme, collapsed_ids, session_id):
1896
2142
  """Update canvas content from agent state."""
1897
- state = get_agent_state()
2143
+ state = get_agent_state(session_id)
1898
2144
  canvas_items = state.get("canvas", [])
1899
2145
  colors = get_colors(theme or "light")
1900
2146
  collapsed_ids = collapsed_ids or []
@@ -1903,6 +2149,50 @@ def update_canvas_content(n_intervals, view_value, theme, collapsed_ids):
1903
2149
  return render_canvas_items(canvas_items, colors, collapsed_ids)
1904
2150
 
1905
2151
 
2152
+ # File tree polling update - refresh file tree during agent execution
2153
+ @app.callback(
2154
+ Output("file-tree", "children", allow_duplicate=True),
2155
+ Input("poll-interval", "n_intervals"),
2156
+ [State("current-workspace-path", "data"),
2157
+ State("theme-store", "data"),
2158
+ State("session-id", "data"),
2159
+ State("sidebar-view-toggle", "value")],
2160
+ prevent_initial_call=True
2161
+ )
2162
+ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, view_value):
2163
+ """Refresh file tree during agent execution to show newly created files.
2164
+
2165
+ This callback runs on each poll interval and refreshes the file tree
2166
+ so that files created by the agent are visible in real-time.
2167
+ Only updates when viewing files (not canvas).
2168
+ """
2169
+ # Only refresh when viewing files panel
2170
+ if view_value != "files":
2171
+ raise PreventUpdate
2172
+
2173
+ # Get agent state to check if we should refresh
2174
+ state = get_agent_state(session_id)
2175
+
2176
+ # Only refresh if agent is running or just finished (within last update window)
2177
+ # This avoids unnecessary refreshes when agent is idle
2178
+ last_update = state.get("last_update", 0)
2179
+ if not state["running"] and (time.time() - last_update) > 2:
2180
+ raise PreventUpdate
2181
+
2182
+ colors = get_colors(theme or "light")
2183
+
2184
+ # Get workspace for this session (virtual or physical)
2185
+ workspace_root = get_workspace_for_session(session_id)
2186
+
2187
+ # Calculate current workspace directory
2188
+ if USE_VIRTUAL_FS:
2189
+ current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
2190
+ else:
2191
+ current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
2192
+
2193
+ # Refresh file tree
2194
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
2195
+
1906
2196
 
1907
2197
  # Open clear canvas confirmation modal
1908
2198
  @app.callback(
@@ -1924,10 +2214,11 @@ def open_clear_canvas_modal(n_clicks):
1924
2214
  Output("collapsed-canvas-items", "data", allow_duplicate=True)],
1925
2215
  [Input("confirm-clear-canvas-btn", "n_clicks"),
1926
2216
  Input("cancel-clear-canvas-btn", "n_clicks")],
1927
- [State("theme-store", "data")],
2217
+ [State("theme-store", "data"),
2218
+ State("session-id", "data")],
1928
2219
  prevent_initial_call=True
1929
2220
  )
1930
- def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme):
2221
+ def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme, session_id):
1931
2222
  """Handle the clear canvas confirmation - either clear or cancel."""
1932
2223
  ctx = callback_context
1933
2224
  if not ctx.triggered:
@@ -1948,15 +2239,31 @@ def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme):
1948
2239
 
1949
2240
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1950
2241
 
2242
+ # Get workspace for this session (virtual or physical)
2243
+ workspace_root = get_workspace_for_session(session_id)
2244
+
1951
2245
  # Archive .canvas folder if it exists (contains canvas.md and all assets)
1952
- canvas_dir = WORKSPACE_ROOT / ".canvas"
1953
- if canvas_dir.exists() and canvas_dir.is_dir():
2246
+ # Note: Archive only works with physical filesystem
2247
+ if not USE_VIRTUAL_FS:
2248
+ canvas_dir = workspace_root / ".canvas"
2249
+ if canvas_dir.exists() and canvas_dir.is_dir():
2250
+ try:
2251
+ archive_dir = workspace_root / f".canvas_{timestamp}"
2252
+ shutil.move(str(canvas_dir), str(archive_dir))
2253
+ print(f"Archived .canvas folder to {archive_dir}")
2254
+ except Exception as e:
2255
+ print(f"Failed to archive .canvas folder: {e}")
2256
+ else:
2257
+ # For virtual FS, just clear the .canvas directory
1954
2258
  try:
1955
- archive_dir = WORKSPACE_ROOT / f".canvas_{timestamp}"
1956
- shutil.move(str(canvas_dir), str(archive_dir))
1957
- print(f"Archived .canvas folder to {archive_dir}")
2259
+ canvas_path = workspace_root.path("/.canvas")
2260
+ if canvas_path.exists():
2261
+ # Clear files in the .canvas directory
2262
+ for item in canvas_path.iterdir():
2263
+ if item.is_file():
2264
+ item.unlink()
1958
2265
  except Exception as e:
1959
- print(f"Failed to archive .canvas folder: {e}")
2266
+ print(f"Failed to clear virtual canvas: {e}")
1960
2267
 
1961
2268
  # Clear canvas in state
1962
2269
  with _agent_state_lock:
@@ -2112,10 +2419,11 @@ def open_delete_confirmation(all_clicks, all_ids):
2112
2419
  Input("cancel-delete-canvas-btn", "n_clicks")],
2113
2420
  [State("delete-canvas-item-id", "data"),
2114
2421
  State("theme-store", "data"),
2115
- State("collapsed-canvas-items", "data")],
2422
+ State("collapsed-canvas-items", "data"),
2423
+ State("session-id", "data")],
2116
2424
  prevent_initial_call=True
2117
2425
  )
2118
- def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, collapsed_ids):
2426
+ def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, collapsed_ids, session_id):
2119
2427
  """Handle the delete confirmation - either delete or cancel."""
2120
2428
  ctx = callback_context
2121
2429
  if not ctx.triggered:
@@ -2131,23 +2439,34 @@ def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, co
2131
2439
  if not confirm_clicks or not item_id:
2132
2440
  raise PreventUpdate
2133
2441
 
2134
- global _agent_state
2135
2442
  colors = get_colors(theme or "light")
2136
2443
  collapsed_ids = collapsed_ids or []
2137
2444
 
2138
- # Remove the item from canvas
2139
- with _agent_state_lock:
2140
- _agent_state["canvas"] = [
2141
- item for item in _agent_state["canvas"]
2142
- if item.get("id") != item_id
2143
- ]
2144
- canvas_items = _agent_state["canvas"].copy()
2445
+ # Get workspace for this session (virtual or physical)
2446
+ workspace_root = get_workspace_for_session(session_id)
2447
+
2448
+ # Remove the item from canvas (session-specific in virtual FS mode)
2449
+ if USE_VIRTUAL_FS and session_id:
2450
+ current_state = _get_session_state(session_id)
2451
+ with _session_agents_lock:
2452
+ current_state["canvas"] = [
2453
+ item for item in current_state.get("canvas", [])
2454
+ if item.get("id") != item_id
2455
+ ]
2456
+ canvas_items = current_state["canvas"].copy()
2457
+ else:
2458
+ with _agent_state_lock:
2459
+ _agent_state["canvas"] = [
2460
+ item for item in _agent_state["canvas"]
2461
+ if item.get("id") != item_id
2462
+ ]
2463
+ canvas_items = _agent_state["canvas"].copy()
2145
2464
 
2146
- # Export updated canvas to markdown file
2147
- try:
2148
- export_canvas_to_markdown(canvas_items, WORKSPACE_ROOT)
2149
- except Exception as e:
2150
- print(f"Failed to export canvas after delete: {e}")
2465
+ # Export updated canvas to markdown file
2466
+ try:
2467
+ export_canvas_to_markdown(canvas_items, workspace_root)
2468
+ except Exception as e:
2469
+ print(f"Failed to export canvas after delete: {e}")
2151
2470
 
2152
2471
  # Remove deleted item from collapsed_ids if present
2153
2472
  new_collapsed_ids = [cid for cid in collapsed_ids if cid != item_id]
@@ -2221,7 +2540,8 @@ def run_app(
2221
2540
  title=None,
2222
2541
  subtitle=None,
2223
2542
  welcome_message=None,
2224
- config_file=None
2543
+ config_file=None,
2544
+ virtual_fs=None
2225
2545
  ):
2226
2546
  """
2227
2547
  Run DeepAgent Dash programmatically.
@@ -2243,6 +2563,9 @@ def run_app(
2243
2563
  subtitle (str, optional): Application subtitle
2244
2564
  welcome_message (str, optional): Welcome message shown on startup (supports markdown)
2245
2565
  config_file (str, optional): Path to config file (default: ./config.py)
2566
+ virtual_fs (bool, optional): Use in-memory virtual filesystem instead of disk.
2567
+ When enabled, each session gets isolated ephemeral storage.
2568
+ Can also be set via DEEPAGENT_SESSION_ISOLATION=true environment variable.
2246
2569
 
2247
2570
  Returns:
2248
2571
  int: Exit code (0 for success, non-zero for error)
@@ -2262,7 +2585,10 @@ def run_app(
2262
2585
  >>> # Without agent (manual mode)
2263
2586
  >>> run_app(workspace="~/my-workspace", debug=True)
2264
2587
  """
2265
- global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, WELCOME_MESSAGE, agent, AGENT_ERROR, args
2588
+ global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, WELCOME_MESSAGE, agent, AGENT_ERROR, args, USE_VIRTUAL_FS
2589
+
2590
+ # Determine virtual filesystem mode (CLI arg > env var > config default)
2591
+ USE_VIRTUAL_FS = virtual_fs if virtual_fs is not None else config.VIRTUAL_FS
2266
2592
 
2267
2593
  # Load config file if specified and exists
2268
2594
  config_module = None
@@ -2332,16 +2658,22 @@ def run_app(
2332
2658
  # Use default config agent
2333
2659
  agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
2334
2660
 
2335
- # Ensure workspace exists
2336
- WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
2661
+ # Update global agent state
2662
+ global _agent_state
2337
2663
 
2338
- # Set environment variable for agent to access workspace
2339
- # This allows user agents to read DEEPAGENT_WORKSPACE_ROOT
2340
- os.environ['DEEPAGENT_WORKSPACE_ROOT'] = str(WORKSPACE_ROOT)
2664
+ # Ensure workspace exists (only for physical filesystem mode)
2665
+ if not USE_VIRTUAL_FS:
2666
+ WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
2341
2667
 
2342
- # Update global state to use the configured workspace
2343
- global _agent_state
2344
- _agent_state["canvas"] = load_canvas_from_markdown(WORKSPACE_ROOT)
2668
+ # Set environment variable for agent to access workspace
2669
+ # This allows user agents to read DEEPAGENT_WORKSPACE_ROOT
2670
+ os.environ['DEEPAGENT_WORKSPACE_ROOT'] = str(WORKSPACE_ROOT)
2671
+
2672
+ # Update global state to use the configured workspace
2673
+ _agent_state["canvas"] = load_canvas_from_markdown(WORKSPACE_ROOT)
2674
+ else:
2675
+ # For virtual FS, canvas is loaded per-session in callbacks
2676
+ _agent_state["canvas"] = []
2345
2677
 
2346
2678
  # Create a mock args object for compatibility with existing code
2347
2679
  class Args:
@@ -2354,9 +2686,13 @@ def run_app(
2354
2686
  print("\n" + "="*50)
2355
2687
  print(f" {APP_TITLE}")
2356
2688
  print("="*50)
2357
- print(f" Workspace: {WORKSPACE_ROOT}")
2358
- if workspace:
2359
- print(f" (from CLI: --workspace {workspace})")
2689
+ if USE_VIRTUAL_FS:
2690
+ print(" Filesystem: Virtual (in-memory, ephemeral)")
2691
+ print(" Sessions are isolated and data is not persisted")
2692
+ else:
2693
+ print(f" Workspace: {WORKSPACE_ROOT}")
2694
+ if workspace:
2695
+ print(f" (from CLI: --workspace {workspace})")
2360
2696
  print(f" Agent: {'Ready' if agent else 'Not available'}")
2361
2697
  if agent_spec:
2362
2698
  print(f" (from CLI: --agent {agent_spec})")
@@ -2372,25 +2708,4 @@ def run_app(
2372
2708
  return 0
2373
2709
  except Exception as e:
2374
2710
  print(f"\n❌ Error running app: {e}")
2375
- return 1
2376
-
2377
-
2378
- # =============================================================================
2379
- # MAIN - BACKWARDS COMPATIBILITY
2380
- # =============================================================================
2381
-
2382
- if __name__ == "__main__":
2383
- # Parse CLI arguments
2384
- args = parse_args()
2385
-
2386
- # When run directly (not as package), use original CLI arg parsing
2387
- sys.exit(run_app(
2388
- workspace=args.workspace if args.workspace else None,
2389
- agent_spec=args.agent if args.agent else None,
2390
- port=args.port if args.port else None,
2391
- host=args.host if args.host else None,
2392
- debug=args.debug if args.debug else (not args.no_debug if args.no_debug else None),
2393
- title=args.title if args.title else None,
2394
- subtitle=args.subtitle if args.subtitle else None,
2395
- config_file=args.config if args.config else None
2396
- ))
2711
+ return 1