cowork-dash 0.1.6__py3-none-any.whl → 0.1.8__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
@@ -10,7 +10,6 @@ import platform
10
10
  import subprocess
11
11
  import threading
12
12
  import time
13
- import argparse
14
13
  import importlib.util
15
14
  from pathlib import Path
16
15
  from datetime import datetime
@@ -18,20 +17,20 @@ from typing import Optional, Dict, Any, List
18
17
  from dotenv import load_dotenv
19
18
  load_dotenv()
20
19
 
21
- 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
22
21
  from dash.exceptions import PreventUpdate
23
22
  import dash_mantine_components as dmc
24
23
  from dash_iconify import DashIconify
25
24
 
26
25
  # Import custom modules
27
- 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
28
27
  from .file_utils import build_file_tree, render_file_tree, read_file_content, get_file_download_data, load_folder_contents
29
28
  from .components import (
30
- format_message, format_loading, format_thinking, format_todos,
31
- 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,
32
30
  format_interrupt
33
31
  )
34
32
  from .layout import create_layout as create_layout_component
33
+ from .virtual_fs import get_session_manager
35
34
 
36
35
  # Import configuration defaults
37
36
  from . import config
@@ -39,88 +38,6 @@ from . import config
39
38
  # Generate thread ID
40
39
  thread_id = str(uuid.uuid4())
41
40
 
42
- # Parse command-line arguments early
43
- def parse_args():
44
- """Parse command-line arguments."""
45
- parser = argparse.ArgumentParser(
46
- description="FastDash Browser - AI Agent Web Interface",
47
- formatter_class=argparse.RawDescriptionHelpFormatter,
48
- epilog="""
49
- Examples:
50
- # Use defaults from config.py
51
- python app.py
52
-
53
- # Override workspace and port
54
- python app.py --workspace ~/my-workspace --port 8080
55
-
56
- # Use custom agent from file
57
- python app.py --agent my_agents.py:my_agent
58
-
59
- # Production mode
60
- python app.py --host 0.0.0.0 --port 80 --no-debug
61
-
62
- # Debug mode with custom workspace
63
- python app.py --debug --workspace /tmp/test-workspace
64
- """
65
- )
66
-
67
- parser.add_argument(
68
- "--workspace",
69
- type=str,
70
- help="Workspace directory path (default: from config.py)"
71
- )
72
-
73
- parser.add_argument(
74
- "--agent",
75
- type=str,
76
- metavar="PATH:OBJECT",
77
- help='Agent specification as "path/to/file.py:object_name" (e.g., "agent.py:agent" or "my_agents.py:custom_agent")'
78
- )
79
-
80
- parser.add_argument(
81
- "--port",
82
- type=int,
83
- help="Port to run on (default: from config.py)"
84
- )
85
-
86
- parser.add_argument(
87
- "--host",
88
- type=str,
89
- help="Host to bind to (default: from config.py)"
90
- )
91
-
92
- parser.add_argument(
93
- "--debug",
94
- action="store_true",
95
- help="Enable debug mode"
96
- )
97
-
98
- parser.add_argument(
99
- "--no-debug",
100
- action="store_true",
101
- help="Disable debug mode"
102
- )
103
-
104
- parser.add_argument(
105
- "--title",
106
- type=str,
107
- help="Application title (default: from config.py)"
108
- )
109
-
110
- parser.add_argument(
111
- "--subtitle",
112
- type=str,
113
- help="Application subtitle (default: from config.py)"
114
- )
115
-
116
- parser.add_argument(
117
- "--config",
118
- type=str,
119
- help="Path to configuration file (default: config.py)"
120
- )
121
-
122
- return parser.parse_args()
123
-
124
41
  def load_agent_from_spec(agent_spec: str):
125
42
  """
126
43
  Load agent from specification string.
@@ -203,14 +120,56 @@ PORT = config.PORT
203
120
  HOST = config.HOST
204
121
  DEBUG = config.DEBUG
205
122
  WELCOME_MESSAGE = config.WELCOME_MESSAGE
123
+ USE_VIRTUAL_FS = config.VIRTUAL_FS # Can be overridden by --virtual-fs CLI arg
206
124
 
207
- # Ensure workspace exists
208
- 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)
209
128
 
210
129
  # Initialize agent from config
211
130
  agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
212
131
 
213
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
+
214
173
  # =============================================================================
215
174
  # STYLING
216
175
  # =============================================================================
@@ -278,13 +237,13 @@ def get_colors(theme: str = "light") -> dict:
278
237
  # AGENT INTERACTION - WITH REAL-TIME STREAMING
279
238
  # =============================================================================
280
239
 
281
- # Global state for streaming updates
240
+ # Global state for streaming updates (used in physical FS mode)
282
241
  _agent_state = {
283
242
  "running": False,
284
243
  "thinking": "",
285
244
  "todos": [],
286
245
  "tool_calls": [], # Current turn's tool calls (reset each turn)
287
- "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)
288
247
  "response": "",
289
248
  "error": None,
290
249
  "interrupt": None, # Track interrupt requests for human-in-the-loop
@@ -294,25 +253,110 @@ _agent_state = {
294
253
  }
295
254
  _agent_state_lock = threading.Lock()
296
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
+ }
278
+
279
+
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()
297
332
 
298
- def request_agent_stop():
299
- """Request the agent to stop execution."""
300
- with _agent_state_lock:
301
- _agent_state["stop_requested"] = True
302
- _agent_state["last_update"] = time.time()
303
333
 
304
- def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None):
305
- """Run agent in background thread and update global state in real-time.
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.
306
336
 
307
337
  Args:
308
338
  message: User message to send to agent
309
339
  resume_data: Optional dict with 'decisions' to resume from interrupt
310
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)
311
342
  """
312
- if not agent:
313
- with _agent_state_lock:
314
- _agent_state["response"] = f"⚠️ {_agent_state['error']}\n\nPlease check your setup and try again."
315
- _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
316
360
  return
317
361
 
318
362
  # Track tool calls by their ID for updating status
@@ -339,17 +383,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
339
383
 
340
384
  def _update_tool_call_result(tool_call_id: str, result: Any, status: str = "success"):
341
385
  """Update a tool call with its result."""
342
- with _agent_state_lock:
343
- for tc in _agent_state["tool_calls"]:
386
+ with state_lock:
387
+ for tc in current_state["tool_calls"]:
344
388
  if tc.get("id") == tool_call_id:
345
389
  tc["result"] = result
346
390
  tc["status"] = status
347
391
  break
348
- _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)
349
399
 
350
400
  try:
351
401
  # Prepare input based on whether we're resuming or starting fresh
352
- stream_config = dict(configurable=dict(thread_id=thread_id))
402
+ stream_config = dict(configurable=dict(thread_id=current_thread_id))
353
403
 
354
404
  if message == "__RESUME__":
355
405
  # Resume from interrupt
@@ -364,24 +414,24 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
364
414
  message_with_context = message
365
415
  agent_input = {"messages": [{"role": "user", "content": message_with_context}]}
366
416
 
367
- 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):
368
418
  # Check if stop was requested
369
- with _agent_state_lock:
370
- if _agent_state.get("stop_requested"):
371
- _agent_state["response"] = _agent_state.get("response", "") + "\n\nAgent stopped by user."
372
- _agent_state["running"] = False
373
- _agent_state["stop_requested"] = False
374
- _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()
375
425
  return
376
426
 
377
427
  # Check for interrupt
378
428
  if isinstance(update, dict) and "__interrupt__" in update:
379
429
  interrupt_value = update["__interrupt__"]
380
430
  interrupt_data = _process_interrupt(interrupt_value)
381
- with _agent_state_lock:
382
- _agent_state["interrupt"] = interrupt_data
383
- _agent_state["running"] = False # Pause until user responds
384
- _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()
385
435
  return # Exit stream, wait for user to resume
386
436
 
387
437
  if isinstance(update, dict):
@@ -400,9 +450,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
400
450
  tool_call_map[serialized["id"]] = serialized
401
451
  new_tool_calls.append(serialized)
402
452
 
403
- with _agent_state_lock:
404
- _agent_state["tool_calls"].extend(new_tool_calls)
405
- _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()
406
456
 
407
457
  elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
408
458
  # Update tool call status when we get the result
@@ -451,9 +501,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
451
501
  thinking_text = content.get('reflection', str(content))
452
502
 
453
503
  # Update state immediately
454
- with _agent_state_lock:
455
- _agent_state["thinking"] = thinking_text
456
- _agent_state["last_update"] = time.time()
504
+ with state_lock:
505
+ current_state["thinking"] = thinking_text
506
+ current_state["last_update"] = time.time()
457
507
 
458
508
  elif last_msg.name == 'write_todos':
459
509
  content = last_msg.content
@@ -473,9 +523,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
473
523
  todos = content
474
524
 
475
525
  # Update state immediately
476
- with _agent_state_lock:
477
- _agent_state["todos"] = todos
478
- _agent_state["last_update"] = time.time()
526
+ with state_lock:
527
+ current_state["todos"] = todos
528
+ current_state["last_update"] = time.time()
479
529
 
480
530
  elif last_msg.name == 'add_to_canvas':
481
531
  content = last_msg.content
@@ -493,15 +543,16 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
493
543
  canvas_item = {"type": "markdown", "data": str(content)}
494
544
 
495
545
  # Update state immediately - append to canvas
496
- with _agent_state_lock:
497
- _agent_state["canvas"].append(canvas_item)
498
- _agent_state["last_update"] = time.time()
546
+ with state_lock:
547
+ current_state["canvas"].append(canvas_item)
548
+ current_state["last_update"] = time.time()
499
549
 
500
- # Also export to markdown file
501
- try:
502
- export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
503
- except Exception as e:
504
- 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}")
505
556
 
506
557
  elif last_msg.name == 'update_canvas_item':
507
558
  content = last_msg.content
@@ -518,22 +569,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
518
569
 
519
570
  item_id = canvas_item.get("id")
520
571
  if item_id:
521
- with _agent_state_lock:
572
+ with state_lock:
522
573
  # Find and replace the item with matching ID
523
- for i, existing in enumerate(_agent_state["canvas"]):
574
+ for i, existing in enumerate(current_state["canvas"]):
524
575
  if existing.get("id") == item_id:
525
- _agent_state["canvas"][i] = canvas_item
576
+ current_state["canvas"][i] = canvas_item
526
577
  break
527
578
  else:
528
579
  # If not found, append as new item
529
- _agent_state["canvas"].append(canvas_item)
530
- _agent_state["last_update"] = time.time()
580
+ current_state["canvas"].append(canvas_item)
581
+ current_state["last_update"] = time.time()
531
582
 
532
- # Export to markdown file
533
- try:
534
- export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
535
- except Exception as e:
536
- 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}")
537
589
 
538
590
  elif last_msg.name == 'remove_canvas_item':
539
591
  content = last_msg.content
@@ -550,18 +602,19 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
550
602
  item_id = None
551
603
 
552
604
  if item_id:
553
- with _agent_state_lock:
554
- _agent_state["canvas"] = [
555
- item for item in _agent_state["canvas"]
605
+ with state_lock:
606
+ current_state["canvas"] = [
607
+ item for item in current_state["canvas"]
556
608
  if item.get("id") != item_id
557
609
  ]
558
- _agent_state["last_update"] = time.time()
610
+ current_state["last_update"] = time.time()
559
611
 
560
- # Export to markdown file
561
- try:
562
- export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
563
- except Exception as e:
564
- 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}")
565
618
 
566
619
  elif last_msg.name in ('execute_cell', 'execute_all_cells'):
567
620
  # Extract canvas_items from cell execution results
@@ -590,17 +643,18 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
590
643
 
591
644
  # Add any canvas items found
592
645
  if canvas_items_to_add:
593
- with _agent_state_lock:
646
+ with state_lock:
594
647
  for item in canvas_items_to_add:
595
648
  if isinstance(item, dict) and item.get('type'):
596
- _agent_state["canvas"].append(item)
597
- _agent_state["last_update"] = time.time()
649
+ current_state["canvas"].append(item)
650
+ current_state["last_update"] = time.time()
598
651
 
599
- # Export to markdown file
600
- try:
601
- export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
602
- except Exception as e:
603
- 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}")
604
658
 
605
659
  elif hasattr(last_msg, 'content'):
606
660
  content = last_msg.content
@@ -618,19 +672,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
618
672
  response_text = " ".join(text_parts).strip()
619
673
 
620
674
  if response_text:
621
- with _agent_state_lock:
622
- _agent_state["response"] = response_text
623
- _agent_state["last_update"] = time.time()
675
+ with state_lock:
676
+ current_state["response"] = response_text
677
+ current_state["last_update"] = time.time()
624
678
 
625
679
  except Exception as e:
626
- with _agent_state_lock:
627
- _agent_state["error"] = str(e)
628
- _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)}"
629
683
 
630
684
  finally:
631
- with _agent_state_lock:
632
- _agent_state["running"] = False
633
- _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()
634
692
 
635
693
 
636
694
  def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
@@ -729,20 +787,29 @@ def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
729
787
 
730
788
  return interrupt_data
731
789
 
732
- 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):
733
791
  """Start agent execution in background thread.
734
792
 
735
793
  Args:
736
794
  message: User message to send to agent
737
795
  resume_data: Optional dict with decisions to resume from interrupt
738
796
  workspace_path: Current workspace directory path to inject into agent context
797
+ session_id: Session ID for virtual FS mode
739
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
+
740
807
  # Reset state but preserve canvas - do it all atomically
741
- with _agent_state_lock:
742
- existing_canvas = _agent_state.get("canvas", []).copy()
808
+ with state_lock:
809
+ existing_canvas = current_state.get("canvas", []).copy()
743
810
 
744
- _agent_state.clear()
745
- _agent_state.update({
811
+ current_state.clear()
812
+ current_state.update({
746
813
  "running": True,
747
814
  "thinking": "",
748
815
  "todos": [],
@@ -757,21 +824,30 @@ def call_agent(message: str, resume_data: Dict = None, workspace_path: str = Non
757
824
  })
758
825
 
759
826
  # Start background thread
760
- 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))
761
828
  thread.daemon = True
762
829
  thread.start()
763
830
 
764
831
 
765
- 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):
766
833
  """Resume agent from an interrupt with the user's decision.
767
834
 
768
835
  Args:
769
836
  decision: User's response/decision text
770
837
  action: One of 'approve', 'reject', 'edit'
771
838
  action_requests: List of action requests from the interrupt (for edit mode)
839
+ session_id: Session ID for virtual FS mode
772
840
  """
773
- with _agent_state_lock:
774
- 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")
775
851
  if not interrupt_data:
776
852
  return
777
853
 
@@ -780,16 +856,16 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
780
856
  action_requests = interrupt_data.get("action_requests", [])
781
857
 
782
858
  # Clear interrupt and set running, but preserve tool_calls and canvas
783
- existing_tool_calls = _agent_state.get("tool_calls", []).copy()
784
- 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()
785
861
 
786
- _agent_state["interrupt"] = None
787
- _agent_state["running"] = True
788
- _agent_state["response"] = "" # Clear any previous response
789
- _agent_state["error"] = None # Clear any previous error
790
- _agent_state["tool_calls"] = existing_tool_calls # Keep existing tool calls
791
- _agent_state["canvas"] = existing_canvas # Keep canvas
792
- _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()
793
869
 
794
870
  # Build decisions list in the format expected by deepagents HITL middleware
795
871
  # Format: {"decisions": [{"type": "approve"}, {"type": "reject", "message": "..."}, ...]}
@@ -813,10 +889,10 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
813
889
  tool_names = [ar.get("tool", "unknown") for ar in action_requests]
814
890
  tool_info = f" ({', '.join(tool_names)})"
815
891
 
816
- with _agent_state_lock:
817
- _agent_state["running"] = False
818
- _agent_state["response"] = f"Action rejected{tool_info}: {reject_message}"
819
- _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()
820
896
 
821
897
  return # Don't resume the agent
822
898
  else: # edit - provide edited action
@@ -846,41 +922,62 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
846
922
 
847
923
  # Start background thread with resume value
848
924
  # Pass a special marker to indicate this is a resume operation
849
- 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))
850
926
  thread.daemon = True
851
927
  thread.start()
852
928
 
853
- def get_agent_state() -> Dict[str, Any]:
929
+ def get_agent_state(session_id: Optional[str] = None) -> Dict[str, Any]:
854
930
  """Get current agent state (thread-safe).
855
931
 
932
+ Args:
933
+ session_id: Session ID for virtual FS mode, None for physical FS mode.
934
+
856
935
  Returns a deep copy of mutable collections to prevent race conditions.
857
936
  """
858
- with _agent_state_lock:
859
- state = _agent_state.copy()
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()
860
946
  # Deep copy mutable collections to prevent race conditions during rendering
861
- state["tool_calls"] = copy.deepcopy(_agent_state["tool_calls"])
862
- state["todos"] = copy.deepcopy(_agent_state["todos"])
863
- state["canvas"] = copy.deepcopy(_agent_state["canvas"])
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"])
864
950
  return state
865
951
 
866
- def reset_agent_state():
952
+
953
+ def reset_agent_state(session_id: Optional[str] = None):
867
954
  """Reset agent state for a fresh session (thread-safe).
868
955
 
869
956
  Called on page load to ensure clean state after browser refresh.
870
- Preserves canvas items loaded from canvas.md.
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.
871
961
  """
872
- with _agent_state_lock:
873
- _agent_state["running"] = False
874
- _agent_state["thinking"] = ""
875
- _agent_state["todos"] = []
876
- _agent_state["tool_calls"] = []
877
- _agent_state["response"] = ""
878
- _agent_state["error"] = None
879
- _agent_state["interrupt"] = None
880
- _agent_state["start_time"] = None
881
- _agent_state["stop_requested"] = False
882
- _agent_state["last_update"] = time.time()
883
- # Note: canvas is preserved - it's loaded from canvas.md on startup
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)
884
981
 
885
982
  # =============================================================================
886
983
  # DASH APP
@@ -927,8 +1024,12 @@ def create_layout():
927
1024
  title = getattr(agent, 'name', None) or APP_TITLE
928
1025
  subtitle = getattr(agent, 'description', None) or APP_SUBTITLE
929
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
+
930
1031
  return create_layout_component(
931
- workspace_root=WORKSPACE_ROOT,
1032
+ workspace_root=workspace_for_layout,
932
1033
  app_title=title,
933
1034
  app_subtitle=subtitle,
934
1035
  colors=COLORS,
@@ -951,30 +1052,35 @@ app.layout = create_layout
951
1052
  @app.callback(
952
1053
  [Output("chat-messages", "children"),
953
1054
  Output("skip-history-render", "data", allow_duplicate=True),
954
- Output("session-initialized", "data", allow_duplicate=True)],
1055
+ Output("session-initialized", "data", allow_duplicate=True),
1056
+ Output("session-id", "data", allow_duplicate=True)],
955
1057
  [Input("chat-history", "data")],
956
1058
  [State("theme-store", "data"),
957
1059
  State("skip-history-render", "data"),
958
- State("session-initialized", "data")],
959
- prevent_initial_call="initial_duplicate"
1060
+ State("session-initialized", "data"),
1061
+ State("session-id", "data")],
1062
+ prevent_initial_call='initial_duplicate'
960
1063
  )
961
- def display_initial_messages(history, theme, skip_render, session_initialized):
1064
+ def display_initial_messages(history, theme, skip_render, session_initialized, session_id):
962
1065
  """Display initial welcome message or chat history.
963
1066
 
964
1067
  On first call (page load), resets agent state for a fresh session.
965
1068
  Skip rendering if skip_render flag is set - this prevents duplicate renders
966
1069
  when poll_agent_updates already handles the rendering.
967
1070
  """
968
- # Reset agent state on page load (first callback trigger)
1071
+ # Initialize session on page load (first callback trigger)
1072
+ new_session_id = session_id
969
1073
  if not session_initialized:
970
- reset_agent_state()
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)
971
1077
 
972
1078
  # Skip if flag is set (poll_agent_updates already rendered)
973
1079
  if skip_render:
974
- return no_update, False, True # Reset skip flag, mark session initialized
1080
+ return no_update, False, True, new_session_id # Reset skip flag, mark session initialized
975
1081
 
976
1082
  if not history:
977
- return [], False, True
1083
+ return [], False, True, new_session_id
978
1084
 
979
1085
  colors = get_colors(theme or "light")
980
1086
  messages = []
@@ -991,7 +1097,41 @@ def display_initial_messages(history, theme, skip_render, session_initialized):
991
1097
  todos_block = format_todos_inline(msg["todos"], colors)
992
1098
  if todos_block:
993
1099
  messages.append(todos_block)
994
- return messages, False, True
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
+
995
1135
 
996
1136
  # Chat callbacks
997
1137
  @app.callback(
@@ -1005,10 +1145,11 @@ def display_initial_messages(history, theme, skip_render, session_initialized):
1005
1145
  [State("chat-input", "value"),
1006
1146
  State("chat-history", "data"),
1007
1147
  State("theme-store", "data"),
1008
- State("current-workspace-path", "data")],
1148
+ State("current-workspace-path", "data"),
1149
+ State("session-id", "data")],
1009
1150
  prevent_initial_call=True
1010
1151
  )
1011
- 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):
1012
1153
  """Phase 1: Immediately show user message and start agent."""
1013
1154
  if not message or not message.strip():
1014
1155
  raise PreventUpdate
@@ -1037,11 +1178,19 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
1037
1178
 
1038
1179
  messages.append(format_loading(colors))
1039
1180
 
1040
- # Calculate full workspace path for agent context
1041
- 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)
1042
1191
 
1043
1192
  # Start agent in background with workspace context
1044
- call_agent(message, workspace_path=str(workspace_full_path))
1193
+ call_agent(message, workspace_path=workspace_full_path, session_id=session_id)
1045
1194
 
1046
1195
  # Enable polling
1047
1196
  return messages, history, "", message, False
@@ -1055,10 +1204,11 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
1055
1204
  Input("poll-interval", "n_intervals"),
1056
1205
  [State("chat-history", "data"),
1057
1206
  State("pending-message", "data"),
1058
- State("theme-store", "data")],
1207
+ State("theme-store", "data"),
1208
+ State("session-id", "data")],
1059
1209
  prevent_initial_call=True
1060
1210
  )
1061
- def poll_agent_updates(n_intervals, history, pending_message, theme):
1211
+ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id):
1062
1212
  """Poll for agent updates and display them in real-time.
1063
1213
 
1064
1214
  Tool calls are stored in history and persist across turns.
@@ -1066,7 +1216,7 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
1066
1216
  - {"role": "user", "content": "..."} - user message
1067
1217
  - {"role": "assistant", "content": "...", "tool_calls": [...]} - assistant message with tool calls
1068
1218
  """
1069
- state = get_agent_state()
1219
+ state = get_agent_state(session_id)
1070
1220
  history = history or []
1071
1221
  colors = get_colors(theme or "light")
1072
1222
 
@@ -1196,11 +1346,12 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
1196
1346
  @app.callback(
1197
1347
  Output("stop-btn", "style"),
1198
1348
  Input("poll-interval", "n_intervals"),
1349
+ State("session-id", "data"),
1199
1350
  prevent_initial_call=True
1200
1351
  )
1201
- def update_stop_button_visibility(n_intervals):
1352
+ def update_stop_button_visibility(n_intervals, session_id):
1202
1353
  """Show stop button when agent is running, hide otherwise."""
1203
- state = get_agent_state()
1354
+ state = get_agent_state(session_id)
1204
1355
  if state.get("running"):
1205
1356
  return {} # Show button (remove display:none)
1206
1357
  else:
@@ -1213,10 +1364,11 @@ def update_stop_button_visibility(n_intervals):
1213
1364
  Output("poll-interval", "disabled", allow_duplicate=True)],
1214
1365
  Input("stop-btn", "n_clicks"),
1215
1366
  [State("chat-history", "data"),
1216
- State("theme-store", "data")],
1367
+ State("theme-store", "data"),
1368
+ State("session-id", "data")],
1217
1369
  prevent_initial_call=True
1218
1370
  )
1219
- def handle_stop_button(n_clicks, history, theme):
1371
+ def handle_stop_button(n_clicks, history, theme, session_id):
1220
1372
  """Handle stop button click to stop agent execution."""
1221
1373
  if not n_clicks:
1222
1374
  raise PreventUpdate
@@ -1225,7 +1377,7 @@ def handle_stop_button(n_clicks, history, theme):
1225
1377
  history = history or []
1226
1378
 
1227
1379
  # Request the agent to stop
1228
- request_agent_stop()
1380
+ request_agent_stop(session_id)
1229
1381
 
1230
1382
  # Render current messages with a stopping indicator
1231
1383
  def render_history_messages(history):
@@ -1267,10 +1419,11 @@ def handle_stop_button(n_clicks, history, theme):
1267
1419
  Input("interrupt-edit-btn", "n_clicks")],
1268
1420
  [State("interrupt-input", "value"),
1269
1421
  State("chat-history", "data"),
1270
- State("theme-store", "data")],
1422
+ State("theme-store", "data"),
1423
+ State("session-id", "data")],
1271
1424
  prevent_initial_call=True
1272
1425
  )
1273
- 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):
1274
1427
  """Handle user response to an interrupt.
1275
1428
 
1276
1429
  Note: Click parameters are required for Dash callback inputs but we use
@@ -1312,7 +1465,7 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1312
1465
  raise PreventUpdate
1313
1466
 
1314
1467
  # Resume the agent with the user's decision
1315
- resume_agent_from_interrupt(decision, action)
1468
+ resume_agent_from_interrupt(decision, action, session_id=session_id)
1316
1469
 
1317
1470
  # Show loading state while agent resumes
1318
1471
  messages = []
@@ -1349,16 +1502,20 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1349
1502
  State({"type": "folder-children", "path": ALL}, "style"),
1350
1503
  State({"type": "folder-icon", "path": ALL}, "style"),
1351
1504
  State({"type": "folder-children", "path": ALL}, "children"),
1352
- State("theme-store", "data")],
1505
+ State("theme-store", "data"),
1506
+ State("session-id", "data")],
1353
1507
  prevent_initial_call=True
1354
1508
  )
1355
- 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):
1356
1510
  """Toggle folder expansion and lazy load contents if needed."""
1357
1511
  ctx = callback_context
1358
1512
  if not ctx.triggered or not any(n_clicks):
1359
1513
  raise PreventUpdate
1360
1514
 
1361
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)
1362
1519
  triggered = ctx.triggered[0]["prop_id"]
1363
1520
  try:
1364
1521
  id_str = triggered.rsplit(".", 1)[0]
@@ -1400,7 +1557,7 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1400
1557
  current_content[0].get("props", {}).get("children") == "Loading..."):
1401
1558
  # Load folder contents using real path
1402
1559
  try:
1403
- folder_items = load_folder_contents(folder_rel_path, WORKSPACE_ROOT)
1560
+ folder_items = load_folder_contents(folder_rel_path, workspace_root)
1404
1561
  loaded_content = render_file_tree(folder_items, colors, STYLES,
1405
1562
  level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
1406
1563
  parent_path=folder_rel_path)
@@ -1455,10 +1612,11 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1455
1612
  State({"type": "folder-select", "path": ALL}, "data-folderpath"),
1456
1613
  State({"type": "folder-select", "path": ALL}, "n_clicks"),
1457
1614
  State("current-workspace-path", "data"),
1458
- State("theme-store", "data")],
1615
+ State("theme-store", "data"),
1616
+ State("session-id", "data")],
1459
1617
  prevent_initial_call=True
1460
1618
  )
1461
- 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):
1462
1620
  """Enter a folder (double-click) or navigate via breadcrumb."""
1463
1621
  ctx = callback_context
1464
1622
  if not ctx.triggered:
@@ -1499,7 +1657,6 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
1499
1657
  for i, folder_id in enumerate(folder_ids):
1500
1658
  if folder_id["path"] == clicked_path:
1501
1659
  current_clicks = folder_clicks[i] if i < len(folder_clicks) else 0
1502
- previous_clicks = prev_clicks[i] if i < len(prev_clicks) else 0
1503
1660
 
1504
1661
  # Only enter on double-click (clicks increased and is even number >= 2)
1505
1662
  if current_clicks and current_clicks >= 2 and current_clicks % 2 == 0:
@@ -1554,8 +1711,14 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
1554
1711
  )
1555
1712
  )
1556
1713
 
1714
+ # Get workspace for this session (virtual or physical)
1715
+ workspace_root = get_workspace_for_session(session_id)
1716
+
1557
1717
  # Calculate the actual workspace path
1558
- 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
1559
1722
 
1560
1723
  # Render new file tree
1561
1724
  file_tree = render_file_tree(
@@ -1576,10 +1739,11 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
1576
1739
  Input({"type": "file-item", "path": ALL}, "n_clicks"),
1577
1740
  [State({"type": "file-item", "path": ALL}, "id"),
1578
1741
  State("file-click-tracker", "data"),
1579
- State("theme-store", "data")],
1742
+ State("theme-store", "data"),
1743
+ State("session-id", "data")],
1580
1744
  prevent_initial_call=True
1581
1745
  )
1582
- 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):
1583
1747
  """Open file in modal - only on actual new clicks."""
1584
1748
  ctx = callback_context
1585
1749
 
@@ -1627,13 +1791,19 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
1627
1791
  # Still need to return updated tracker to avoid stale state
1628
1792
  raise PreventUpdate
1629
1793
 
1794
+ # Get workspace for this session (virtual or physical)
1795
+ workspace_root = get_workspace_for_session(session_id)
1796
+
1630
1797
  # Verify file exists and is a file
1631
- 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
1632
1802
  if not full_path.exists() or not full_path.is_file():
1633
1803
  raise PreventUpdate
1634
1804
 
1635
1805
  colors = get_colors(theme or "light")
1636
- content, is_text, error = read_file_content(WORKSPACE_ROOT, file_path)
1806
+ content, is_text, error = read_file_content(workspace_root, file_path)
1637
1807
  filename = Path(file_path).name
1638
1808
 
1639
1809
  if is_text and content:
@@ -1672,10 +1842,11 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
1672
1842
  @app.callback(
1673
1843
  Output("file-download", "data", allow_duplicate=True),
1674
1844
  Input("modal-download-btn", "n_clicks"),
1675
- State("file-to-view", "data"),
1845
+ [State("file-to-view", "data"),
1846
+ State("session-id", "data")],
1676
1847
  prevent_initial_call=True
1677
1848
  )
1678
- def download_from_modal(n_clicks, file_path):
1849
+ def download_from_modal(n_clicks, file_path, session_id):
1679
1850
  """Download file from modal."""
1680
1851
  ctx = callback_context
1681
1852
  if not ctx.triggered:
@@ -1689,7 +1860,10 @@ def download_from_modal(n_clicks, file_path):
1689
1860
  if not n_clicks or not file_path:
1690
1861
  raise PreventUpdate
1691
1862
 
1692
- 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)
1693
1867
  if not b64:
1694
1868
  raise PreventUpdate
1695
1869
 
@@ -1706,10 +1880,15 @@ def open_terminal(n_clicks):
1706
1880
  """Open system terminal at workspace directory."""
1707
1881
  if not n_clicks:
1708
1882
  raise PreventUpdate
1709
-
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
+
1710
1889
  workspace_path = str(WORKSPACE_ROOT)
1711
1890
  system = platform.system()
1712
-
1891
+
1713
1892
  try:
1714
1893
  if system == "Darwin": # macOS
1715
1894
  subprocess.Popen(["open", "-a", "Terminal", workspace_path])
@@ -1731,7 +1910,7 @@ def open_terminal(n_clicks):
1731
1910
  continue
1732
1911
  except Exception as e:
1733
1912
  print(f"Failed to open terminal: {e}")
1734
-
1913
+
1735
1914
  raise PreventUpdate
1736
1915
 
1737
1916
 
@@ -1742,27 +1921,31 @@ def open_terminal(n_clicks):
1742
1921
  Input("refresh-btn", "n_clicks"),
1743
1922
  [State("current-workspace-path", "data"),
1744
1923
  State("theme-store", "data"),
1745
- State("collapsed-canvas-items", "data")],
1924
+ State("collapsed-canvas-items", "data"),
1925
+ State("session-id", "data")],
1746
1926
  prevent_initial_call=True
1747
1927
  )
1748
- def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
1928
+ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_id):
1749
1929
  """Refresh both file tree and canvas content."""
1750
- global _agent_state
1751
1930
  colors = get_colors(theme or "light")
1752
1931
  collapsed_ids = collapsed_ids or []
1753
1932
 
1933
+ # Get workspace for this session (virtual or physical)
1934
+ workspace_root = get_workspace_for_session(session_id)
1935
+
1754
1936
  # Calculate current workspace directory
1755
- 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
1756
1941
 
1757
1942
  # Refresh file tree for current workspace
1758
1943
  file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
1759
1944
 
1760
- # Refresh canvas by reloading from .canvas/canvas.md file (always from original root)
1761
- canvas_items = load_canvas_from_markdown(WORKSPACE_ROOT)
1762
-
1763
- # Update agent state with reloaded canvas
1764
- with _agent_state_lock:
1765
- _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", [])
1766
1949
 
1767
1950
  # Render the canvas items with preserved collapsed state
1768
1951
  canvas_content = render_canvas_items(canvas_items, colors, collapsed_ids)
@@ -1776,17 +1959,25 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
1776
1959
  Input("file-upload-sidebar", "contents"),
1777
1960
  [State("file-upload-sidebar", "filename"),
1778
1961
  State("current-workspace-path", "data"),
1779
- State("theme-store", "data")],
1962
+ State("theme-store", "data"),
1963
+ State("session-id", "data")],
1780
1964
  prevent_initial_call=True
1781
1965
  )
1782
- def handle_sidebar_upload(contents, filenames, current_workspace, theme):
1966
+ def handle_sidebar_upload(contents, filenames, current_workspace, theme, session_id):
1783
1967
  """Handle file uploads from sidebar button to current workspace."""
1784
1968
  if not contents:
1785
1969
  raise PreventUpdate
1786
1970
 
1787
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
+
1788
1976
  # Calculate current workspace directory
1789
- 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
1790
1981
 
1791
1982
  for content, filename in zip(contents, filenames):
1792
1983
  try:
@@ -1842,10 +2033,11 @@ def toggle_create_folder_modal(open_clicks, cancel_clicks, confirm_clicks, is_op
1842
2033
  Input("confirm-folder-btn", "n_clicks"),
1843
2034
  [State("new-folder-name", "value"),
1844
2035
  State("current-workspace-path", "data"),
1845
- State("theme-store", "data")],
2036
+ State("theme-store", "data"),
2037
+ State("session-id", "data")],
1846
2038
  prevent_initial_call=True
1847
2039
  )
1848
- def create_folder(n_clicks, folder_name, current_workspace, theme):
2040
+ def create_folder(n_clicks, folder_name, current_workspace, theme, session_id):
1849
2041
  """Create a new folder in the current workspace directory."""
1850
2042
  if not n_clicks:
1851
2043
  raise PreventUpdate
@@ -1862,8 +2054,15 @@ def create_folder(n_clicks, folder_name, current_workspace, theme):
1862
2054
  if any(char in folder_name for char in invalid_chars):
1863
2055
  return no_update, f"Folder name cannot contain: {' '.join(invalid_chars)}", no_update
1864
2056
 
2057
+ # Get workspace for this session (virtual or physical)
2058
+ workspace_root = get_workspace_for_session(session_id)
2059
+
1865
2060
  # Calculate current workspace directory
1866
- 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
+
1867
2066
  folder_path = current_workspace_dir / folder_name
1868
2067
 
1869
2068
  if folder_path.exists():
@@ -1935,12 +2134,13 @@ def toggle_view(view_value):
1935
2134
  [Input("poll-interval", "n_intervals"),
1936
2135
  Input("sidebar-view-toggle", "value")],
1937
2136
  [State("theme-store", "data"),
1938
- State("collapsed-canvas-items", "data")],
2137
+ State("collapsed-canvas-items", "data"),
2138
+ State("session-id", "data")],
1939
2139
  prevent_initial_call=False
1940
2140
  )
1941
- 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):
1942
2142
  """Update canvas content from agent state."""
1943
- state = get_agent_state()
2143
+ state = get_agent_state(session_id)
1944
2144
  canvas_items = state.get("canvas", [])
1945
2145
  colors = get_colors(theme or "light")
1946
2146
  collapsed_ids = collapsed_ids or []
@@ -1949,6 +2149,50 @@ def update_canvas_content(n_intervals, view_value, theme, collapsed_ids):
1949
2149
  return render_canvas_items(canvas_items, colors, collapsed_ids)
1950
2150
 
1951
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
+
1952
2196
 
1953
2197
  # Open clear canvas confirmation modal
1954
2198
  @app.callback(
@@ -1970,10 +2214,11 @@ def open_clear_canvas_modal(n_clicks):
1970
2214
  Output("collapsed-canvas-items", "data", allow_duplicate=True)],
1971
2215
  [Input("confirm-clear-canvas-btn", "n_clicks"),
1972
2216
  Input("cancel-clear-canvas-btn", "n_clicks")],
1973
- [State("theme-store", "data")],
2217
+ [State("theme-store", "data"),
2218
+ State("session-id", "data")],
1974
2219
  prevent_initial_call=True
1975
2220
  )
1976
- def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme):
2221
+ def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme, session_id):
1977
2222
  """Handle the clear canvas confirmation - either clear or cancel."""
1978
2223
  ctx = callback_context
1979
2224
  if not ctx.triggered:
@@ -1994,15 +2239,31 @@ def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme):
1994
2239
 
1995
2240
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1996
2241
 
2242
+ # Get workspace for this session (virtual or physical)
2243
+ workspace_root = get_workspace_for_session(session_id)
2244
+
1997
2245
  # Archive .canvas folder if it exists (contains canvas.md and all assets)
1998
- canvas_dir = WORKSPACE_ROOT / ".canvas"
1999
- 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
2000
2258
  try:
2001
- archive_dir = WORKSPACE_ROOT / f".canvas_{timestamp}"
2002
- shutil.move(str(canvas_dir), str(archive_dir))
2003
- 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()
2004
2265
  except Exception as e:
2005
- print(f"Failed to archive .canvas folder: {e}")
2266
+ print(f"Failed to clear virtual canvas: {e}")
2006
2267
 
2007
2268
  # Clear canvas in state
2008
2269
  with _agent_state_lock:
@@ -2158,10 +2419,11 @@ def open_delete_confirmation(all_clicks, all_ids):
2158
2419
  Input("cancel-delete-canvas-btn", "n_clicks")],
2159
2420
  [State("delete-canvas-item-id", "data"),
2160
2421
  State("theme-store", "data"),
2161
- State("collapsed-canvas-items", "data")],
2422
+ State("collapsed-canvas-items", "data"),
2423
+ State("session-id", "data")],
2162
2424
  prevent_initial_call=True
2163
2425
  )
2164
- 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):
2165
2427
  """Handle the delete confirmation - either delete or cancel."""
2166
2428
  ctx = callback_context
2167
2429
  if not ctx.triggered:
@@ -2177,23 +2439,34 @@ def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, co
2177
2439
  if not confirm_clicks or not item_id:
2178
2440
  raise PreventUpdate
2179
2441
 
2180
- global _agent_state
2181
2442
  colors = get_colors(theme or "light")
2182
2443
  collapsed_ids = collapsed_ids or []
2183
2444
 
2184
- # Remove the item from canvas
2185
- with _agent_state_lock:
2186
- _agent_state["canvas"] = [
2187
- item for item in _agent_state["canvas"]
2188
- if item.get("id") != item_id
2189
- ]
2190
- 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()
2191
2464
 
2192
- # Export updated canvas to markdown file
2193
- try:
2194
- export_canvas_to_markdown(canvas_items, WORKSPACE_ROOT)
2195
- except Exception as e:
2196
- 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}")
2197
2470
 
2198
2471
  # Remove deleted item from collapsed_ids if present
2199
2472
  new_collapsed_ids = [cid for cid in collapsed_ids if cid != item_id]
@@ -2267,7 +2540,8 @@ def run_app(
2267
2540
  title=None,
2268
2541
  subtitle=None,
2269
2542
  welcome_message=None,
2270
- config_file=None
2543
+ config_file=None,
2544
+ virtual_fs=None
2271
2545
  ):
2272
2546
  """
2273
2547
  Run DeepAgent Dash programmatically.
@@ -2289,6 +2563,9 @@ def run_app(
2289
2563
  subtitle (str, optional): Application subtitle
2290
2564
  welcome_message (str, optional): Welcome message shown on startup (supports markdown)
2291
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.
2292
2569
 
2293
2570
  Returns:
2294
2571
  int: Exit code (0 for success, non-zero for error)
@@ -2308,7 +2585,10 @@ def run_app(
2308
2585
  >>> # Without agent (manual mode)
2309
2586
  >>> run_app(workspace="~/my-workspace", debug=True)
2310
2587
  """
2311
- 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
2312
2592
 
2313
2593
  # Load config file if specified and exists
2314
2594
  config_module = None
@@ -2378,16 +2658,22 @@ def run_app(
2378
2658
  # Use default config agent
2379
2659
  agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
2380
2660
 
2381
- # Ensure workspace exists
2382
- WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
2661
+ # Update global agent state
2662
+ global _agent_state
2383
2663
 
2384
- # Set environment variable for agent to access workspace
2385
- # This allows user agents to read DEEPAGENT_WORKSPACE_ROOT
2386
- 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)
2387
2667
 
2388
- # Update global state to use the configured workspace
2389
- global _agent_state
2390
- _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"] = []
2391
2677
 
2392
2678
  # Create a mock args object for compatibility with existing code
2393
2679
  class Args:
@@ -2400,9 +2686,13 @@ def run_app(
2400
2686
  print("\n" + "="*50)
2401
2687
  print(f" {APP_TITLE}")
2402
2688
  print("="*50)
2403
- print(f" Workspace: {WORKSPACE_ROOT}")
2404
- if workspace:
2405
- 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})")
2406
2696
  print(f" Agent: {'Ready' if agent else 'Not available'}")
2407
2697
  if agent_spec:
2408
2698
  print(f" (from CLI: --agent {agent_spec})")
@@ -2418,25 +2708,4 @@ def run_app(
2418
2708
  return 0
2419
2709
  except Exception as e:
2420
2710
  print(f"\n❌ Error running app: {e}")
2421
- return 1
2422
-
2423
-
2424
- # =============================================================================
2425
- # MAIN - BACKWARDS COMPATIBILITY
2426
- # =============================================================================
2427
-
2428
- if __name__ == "__main__":
2429
- # Parse CLI arguments
2430
- args = parse_args()
2431
-
2432
- # When run directly (not as package), use original CLI arg parsing
2433
- sys.exit(run_app(
2434
- workspace=args.workspace if args.workspace else None,
2435
- agent_spec=args.agent if args.agent else None,
2436
- port=args.port if args.port else None,
2437
- host=args.host if args.host else None,
2438
- debug=args.debug if args.debug else (not args.no_debug if args.no_debug else None),
2439
- title=args.title if args.title else None,
2440
- subtitle=args.subtitle if args.subtitle else None,
2441
- config_file=args.config if args.config else None
2442
- ))
2711
+ return 1