taskflow-agent 0.2.2__tar.gz → 0.3.0__tar.gz

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.
@@ -10,3 +10,4 @@ logs/
10
10
  data/taskflow-web.pid
11
11
  data/agent_memory.md
12
12
  data/repos.json
13
+ data/workflows/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskflow-agent
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Lightweight project and task manager with MCP tools for Claude Code
5
5
  Project-URL: Repository, https://github.com/henrysouchien/taskflow-agent
6
6
  Author: Henry Chien
@@ -31,31 +31,92 @@ SQLite backend, 23+ MCP tools, web UI with embedded AI chat, and a FastAPI REST
31
31
 
32
32
  ## Features
33
33
 
34
- - **23+ MCP tools** — projects, tasks, sections, goals, daily focus, search, views, repo integration, service lifecycle
34
+ - **23+ MCP tools** — projects, tasks, sections, goals, daily focus, search, views, repo integration
35
35
  - **Web UI** — dark-theme SPA with project boards, task details, inline editing
36
36
  - **AI chat** — embedded Claude chat with workspace awareness, tool access, and persistent memory
37
37
  - **Daily focus** — Today view with goals, focus list, and AI-assisted daily planning
38
38
  - **Goals** — timeframe-scoped goals (day/week/month/quarter) that guide daily prioritization
39
- - **Agent memory** — persistent context across chat sessions (preferences, decisions, patterns)
39
+ - **Agent memory** — persistent context across chat sessions
40
40
  - **Repo integration** — read-only git status, recent commits, and TODOs across connected repos
41
41
  - **FTS search** — full-text search across task names and notes
42
- - **Server-side chat storage** — chat history persisted in SQLite with compaction
43
42
  - **Asana import** — bulk import from Asana CSV exports
44
- - **Service management** — start/stop the web server via MCP tools or Makefile
45
43
 
46
44
  ## Quick Start
47
45
 
48
46
  ```bash
49
- pip install -e ".[web]"
47
+ pip install taskflow-agent[web]
50
48
 
51
49
  # Start the MCP server (for Claude Code)
52
50
  taskflow
53
51
 
54
- # Start the web UI
55
- make serve # foreground
56
- taskflow-web # via CLI
52
+ # Start the web UI (port 8787)
53
+ taskflow-web
57
54
  ```
58
55
 
56
+ ## Setup
57
+
58
+ ### 1. Environment
59
+
60
+ Create a `.env` file in your working directory:
61
+
62
+ ```bash
63
+ # Auth — pick one mode
64
+ ANTHROPIC_AUTH_MODE=api_key # "oauth" or "api_key"
65
+ ANTHROPIC_API_KEY=sk-ant-... # if using api_key mode
66
+ ANTHROPIC_AUTH_TOKEN=... # if using oauth mode
67
+
68
+ # Optional
69
+ ANTHROPIC_MODEL=claude-sonnet-4-6 # default model for chat
70
+ LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
71
+ ```
72
+
73
+ The MCP server (task management tools) works without auth. Auth is only needed for the web UI's embedded AI chat.
74
+
75
+ ### 2. Register MCP Server
76
+
77
+ Add to `~/.claude.json`:
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "taskflow": {
83
+ "type": "stdio",
84
+ "command": "path/to/venv/bin/python",
85
+ "args": ["-m", "src.server"],
86
+ "cwd": "path/to/taskflow"
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ ### 3. Configure Repos (optional)
93
+
94
+ Create `data/repos.json` to connect git repos for status tracking:
95
+
96
+ ```json
97
+ {
98
+ "my-project": "/absolute/path/to/my-project",
99
+ "another-repo": "/absolute/path/to/another-repo"
100
+ }
101
+ ```
102
+
103
+ `tf_repo_list` and `tf_repo_status` use this to show branch, state, recent commits, and TODOs. Read-only.
104
+
105
+ ### 4. Run
106
+
107
+ ```bash
108
+ # Web UI
109
+ taskflow-web # port 8787
110
+ # or with make (if developing from source):
111
+ make serve # foreground
112
+ make dev # with auto-reload
113
+
114
+ # MCP server only
115
+ taskflow
116
+ ```
117
+
118
+ Open `http://localhost:8787`.
119
+
59
120
  ## MCP Tools
60
121
 
61
122
  ### Projects
@@ -123,41 +184,36 @@ taskflow-web # via CLI
123
184
  | `tf_serve_start` | Start web server in background |
124
185
  | `tf_serve_stop` | Stop web server |
125
186
 
126
- ## MCP Registration
187
+ ## Embedded Chat
127
188
 
128
- Add to `~/.claude.json`:
189
+ The web UI includes an AI chat panel (toggle with `C`) powered by [ai-agent-gateway](https://pypi.org/project/ai-agent-gateway/).
129
190
 
130
- ```json
131
- {
132
- "mcpServers": {
133
- "taskflow": {
134
- "type": "stdio",
135
- "command": "path/to/venv/bin/python",
136
- "args": ["-m", "src.server"],
137
- "cwd": "path/to/taskflow"
138
- }
139
- }
140
- }
141
- ```
142
-
143
- ## Web UI
191
+ ### What the chat agent can do
144
192
 
145
- Start the web server on port 8787:
193
+ - All `tf_*` tools manage projects, tasks, goals, focus
194
+ - `read_file` / `list_dir` / `run_shell` — filesystem access
195
+ - `notes_search` / `notes_read` — Apple Notes integration
196
+ - `tf_memory_read` / `tf_memory_update` — persistent memory across sessions (stored in `data/agent_memory.md`, 12 KB max)
197
+ - `load_tools` — dynamically load any MCP server from `~/.claude.json` on demand
146
198
 
147
- ```bash
148
- make serve # foreground, Ctrl-C to stop
149
- make dev # with auto-reload
150
- make status # check if running
151
- make stop # stop the server
152
- ```
199
+ ### Deferred MCP Servers
153
200
 
154
- Or manage via MCP tools from Claude Code ask Claude to "start the taskflow server."
201
+ The chat agent can load any `stdio`-type MCP server registered in your `~/.claude.json` on demand. The agent calls `load_tools("server-name")` and gains access to that server's tools for the session.
155
202
 
156
203
  ## Database
157
204
 
158
- SQLite with WAL mode. Tables: `projects`, `sections`, `tasks`, `tags`, `task_tags`, `tasks_fts` (FTS5), `goals`, `today_focus`, `chat_messages`.
205
+ SQLite with WAL mode. Created automatically on first run.
206
+
207
+ Tables: `projects`, `sections`, `tasks`, `tags`, `task_tags`, `tasks_fts` (FTS5), `goals`, `daily_focus`, `chat_messages`.
208
+
209
+ ## Asana Import
210
+
211
+ ```bash
212
+ # Via MCP tool:
213
+ tf_import_asana directory=/path/to/Asana-Export/
214
+ ```
159
215
 
160
- Database is created automatically on first run via `db.init_db()`.
216
+ Import is additive re-importing creates duplicates. Delete `taskflow.db` first for a clean re-import.
161
217
 
162
218
  ## License
163
219
 
@@ -6,31 +6,92 @@ SQLite backend, 23+ MCP tools, web UI with embedded AI chat, and a FastAPI REST
6
6
 
7
7
  ## Features
8
8
 
9
- - **23+ MCP tools** — projects, tasks, sections, goals, daily focus, search, views, repo integration, service lifecycle
9
+ - **23+ MCP tools** — projects, tasks, sections, goals, daily focus, search, views, repo integration
10
10
  - **Web UI** — dark-theme SPA with project boards, task details, inline editing
11
11
  - **AI chat** — embedded Claude chat with workspace awareness, tool access, and persistent memory
12
12
  - **Daily focus** — Today view with goals, focus list, and AI-assisted daily planning
13
13
  - **Goals** — timeframe-scoped goals (day/week/month/quarter) that guide daily prioritization
14
- - **Agent memory** — persistent context across chat sessions (preferences, decisions, patterns)
14
+ - **Agent memory** — persistent context across chat sessions
15
15
  - **Repo integration** — read-only git status, recent commits, and TODOs across connected repos
16
16
  - **FTS search** — full-text search across task names and notes
17
- - **Server-side chat storage** — chat history persisted in SQLite with compaction
18
17
  - **Asana import** — bulk import from Asana CSV exports
19
- - **Service management** — start/stop the web server via MCP tools or Makefile
20
18
 
21
19
  ## Quick Start
22
20
 
23
21
  ```bash
24
- pip install -e ".[web]"
22
+ pip install taskflow-agent[web]
25
23
 
26
24
  # Start the MCP server (for Claude Code)
27
25
  taskflow
28
26
 
29
- # Start the web UI
30
- make serve # foreground
31
- taskflow-web # via CLI
27
+ # Start the web UI (port 8787)
28
+ taskflow-web
32
29
  ```
33
30
 
31
+ ## Setup
32
+
33
+ ### 1. Environment
34
+
35
+ Create a `.env` file in your working directory:
36
+
37
+ ```bash
38
+ # Auth — pick one mode
39
+ ANTHROPIC_AUTH_MODE=api_key # "oauth" or "api_key"
40
+ ANTHROPIC_API_KEY=sk-ant-... # if using api_key mode
41
+ ANTHROPIC_AUTH_TOKEN=... # if using oauth mode
42
+
43
+ # Optional
44
+ ANTHROPIC_MODEL=claude-sonnet-4-6 # default model for chat
45
+ LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
46
+ ```
47
+
48
+ The MCP server (task management tools) works without auth. Auth is only needed for the web UI's embedded AI chat.
49
+
50
+ ### 2. Register MCP Server
51
+
52
+ Add to `~/.claude.json`:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "taskflow": {
58
+ "type": "stdio",
59
+ "command": "path/to/venv/bin/python",
60
+ "args": ["-m", "src.server"],
61
+ "cwd": "path/to/taskflow"
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ### 3. Configure Repos (optional)
68
+
69
+ Create `data/repos.json` to connect git repos for status tracking:
70
+
71
+ ```json
72
+ {
73
+ "my-project": "/absolute/path/to/my-project",
74
+ "another-repo": "/absolute/path/to/another-repo"
75
+ }
76
+ ```
77
+
78
+ `tf_repo_list` and `tf_repo_status` use this to show branch, state, recent commits, and TODOs. Read-only.
79
+
80
+ ### 4. Run
81
+
82
+ ```bash
83
+ # Web UI
84
+ taskflow-web # port 8787
85
+ # or with make (if developing from source):
86
+ make serve # foreground
87
+ make dev # with auto-reload
88
+
89
+ # MCP server only
90
+ taskflow
91
+ ```
92
+
93
+ Open `http://localhost:8787`.
94
+
34
95
  ## MCP Tools
35
96
 
36
97
  ### Projects
@@ -98,41 +159,36 @@ taskflow-web # via CLI
98
159
  | `tf_serve_start` | Start web server in background |
99
160
  | `tf_serve_stop` | Stop web server |
100
161
 
101
- ## MCP Registration
162
+ ## Embedded Chat
102
163
 
103
- Add to `~/.claude.json`:
164
+ The web UI includes an AI chat panel (toggle with `C`) powered by [ai-agent-gateway](https://pypi.org/project/ai-agent-gateway/).
104
165
 
105
- ```json
106
- {
107
- "mcpServers": {
108
- "taskflow": {
109
- "type": "stdio",
110
- "command": "path/to/venv/bin/python",
111
- "args": ["-m", "src.server"],
112
- "cwd": "path/to/taskflow"
113
- }
114
- }
115
- }
116
- ```
117
-
118
- ## Web UI
166
+ ### What the chat agent can do
119
167
 
120
- Start the web server on port 8787:
168
+ - All `tf_*` tools manage projects, tasks, goals, focus
169
+ - `read_file` / `list_dir` / `run_shell` — filesystem access
170
+ - `notes_search` / `notes_read` — Apple Notes integration
171
+ - `tf_memory_read` / `tf_memory_update` — persistent memory across sessions (stored in `data/agent_memory.md`, 12 KB max)
172
+ - `load_tools` — dynamically load any MCP server from `~/.claude.json` on demand
121
173
 
122
- ```bash
123
- make serve # foreground, Ctrl-C to stop
124
- make dev # with auto-reload
125
- make status # check if running
126
- make stop # stop the server
127
- ```
174
+ ### Deferred MCP Servers
128
175
 
129
- Or manage via MCP tools from Claude Code ask Claude to "start the taskflow server."
176
+ The chat agent can load any `stdio`-type MCP server registered in your `~/.claude.json` on demand. The agent calls `load_tools("server-name")` and gains access to that server's tools for the session.
130
177
 
131
178
  ## Database
132
179
 
133
- SQLite with WAL mode. Tables: `projects`, `sections`, `tasks`, `tags`, `task_tags`, `tasks_fts` (FTS5), `goals`, `today_focus`, `chat_messages`.
180
+ SQLite with WAL mode. Created automatically on first run.
181
+
182
+ Tables: `projects`, `sections`, `tasks`, `tags`, `task_tags`, `tasks_fts` (FTS5), `goals`, `daily_focus`, `chat_messages`.
183
+
184
+ ## Asana Import
185
+
186
+ ```bash
187
+ # Via MCP tool:
188
+ tf_import_asana directory=/path/to/Asana-Export/
189
+ ```
134
190
 
135
- Database is created automatically on first run via `db.init_db()`.
191
+ Import is additive re-importing creates duplicates. Delete `taskflow.db` first for a clean re-import.
136
192
 
137
193
  ## License
138
194
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "taskflow-agent"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Lightweight project and task manager with MCP tools for Claude Code"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -14,7 +14,7 @@ from typing import Optional
14
14
 
15
15
  from mcp.server.fastmcp import FastMCP
16
16
 
17
- from . import db
17
+ from . import db, workflows
18
18
 
19
19
  mcp = FastMCP(
20
20
  "taskflow",
@@ -28,6 +28,7 @@ mcp = FastMCP(
28
28
  db.init_db()
29
29
 
30
30
  _PROJECT_ROOT = Path(__file__).resolve().parent.parent
31
+ _VENV_PYTHON = _PROJECT_ROOT / "venv" / "bin" / "python"
31
32
  _PID_FILE = _PROJECT_ROOT / "data" / "taskflow-web.pid"
32
33
  _LOG_DIR = _PROJECT_ROOT / "logs"
33
34
  _LOG_FILE = _LOG_DIR / "web.log"
@@ -659,7 +660,7 @@ def tf_serve_start() -> str:
659
660
  log_fh = open(_LOG_FILE, "a")
660
661
  try:
661
662
  proc = subprocess.Popen(
662
- [sys.executable, "-m", "src.web"],
663
+ [str(_VENV_PYTHON) if _VENV_PYTHON.exists() else sys.executable, "-m", "src.web"],
663
664
  cwd=str(_PROJECT_ROOT),
664
665
  stdout=log_fh,
665
666
  stderr=subprocess.STDOUT,
@@ -750,6 +751,39 @@ def tf_repo_status(repo: str = "all", commits: int = 10) -> str:
750
751
  return _json(repos.repo_status(repo, commit_count=commits))
751
752
 
752
753
 
754
+ @mcp.tool()
755
+ def tf_workflow_list() -> str:
756
+ """List available workflow templates."""
757
+ workflow_items = workflows.list_workflows()
758
+ return _json({"workflows": workflow_items, "count": len(workflow_items)})
759
+
760
+
761
+ @mcp.tool()
762
+ def tf_workflow_get(slug: str) -> str:
763
+ """Read a workflow template by slug."""
764
+ try:
765
+ workflow = workflows.get_workflow(slug)
766
+ except ValueError as exc:
767
+ return _error(str(exc))
768
+ except OSError as exc:
769
+ return _error(f"Could not read workflow '{slug}': {exc}")
770
+ if workflow is None:
771
+ return _error(f"Workflow '{slug}' not found")
772
+ return _json(workflow)
773
+
774
+
775
+ @mcp.tool()
776
+ def tf_workflow_save(slug: str, content: str) -> str:
777
+ """Create or update a workflow template."""
778
+ try:
779
+ result = workflows.save_workflow(slug, content)
780
+ except ValueError as exc:
781
+ return _error(str(exc))
782
+ except OSError as exc:
783
+ return _error(f"Could not write workflow '{slug}': {exc}")
784
+ return _json(result)
785
+
786
+
753
787
  def main():
754
788
  mcp.run()
755
789
 
@@ -10,6 +10,7 @@ import os
10
10
  import sqlite3
11
11
  import subprocess
12
12
  import time
13
+ from datetime import date as dt_date
13
14
  from logging.handlers import RotatingFileHandler
14
15
  from pathlib import Path
15
16
 
@@ -53,13 +54,14 @@ logging.getLogger("claude_gateway").addHandler(_file_handler)
53
54
  from typing import Any, Literal, Optional
54
55
  from uuid import uuid4
55
56
 
56
- from claude_gateway import AgentRunner, EventLog, McpClientManager, ToolDispatcher
57
+ from claude_gateway import AgentRunner, EventLog, McpClientManager
58
+ from claude_gateway.tool_dispatcher import ToolDispatcher
57
59
  from fastapi import FastAPI, HTTPException, Request
58
60
  from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
59
61
  from fastapi.staticfiles import StaticFiles
60
62
  from pydantic import BaseModel, Field
61
63
 
62
- from . import db
64
+ from . import db, workflows
63
65
 
64
66
  app = FastAPI(title="Taskflow")
65
67
 
@@ -147,6 +149,7 @@ class TaskUpdate(BaseModel):
147
149
  due_date: Optional[str] = None
148
150
  tags: Optional[str] = None
149
151
  section_id: Optional[int] = None
152
+ position: Optional[int] = None
150
153
 
151
154
 
152
155
  class ProjectCreate(BaseModel):
@@ -701,15 +704,19 @@ that code projects get naturally: everything in one place, AI collaborator, clea
701
704
 
702
705
  Structure follows three layers:
703
706
  - Project plan = the orientation ("what are we doing and why") — freeform markdown
704
- - Sections = independent workstreams, each with their own plan
707
+ - Sections = PHASES IN A SEQUENCE, not categories. They answer "what order do I do this in."
708
+ Good: "Phase 1: Schema", "Phase 2: API". Bad: "API", "Frontend", "Database" (that's just filing).
709
+ Do NOT default to grouping by category — always think about sequencing and what comes next.
705
710
  - Tasks = atomic action items extracted from plans
706
711
 
707
712
  Key principles:
708
713
  - Plans before tasks — think first, structure second, execute third. Don't jump to creating tasks.
709
714
  - Goals set direction — concrete targets (not vague aspirations) that drive daily focus choices.
710
- - The daily loop: Goals "what's highest leverage today?" Today view → execute → review.
715
+ - Daily planning = prioritization + scoping against time. What's highest leverage? What unblocks things?
716
+ How much time is available? What fits, what gets cut? Rough-estimate in conversation, not schema fields.
711
717
  - Collaborator, not task bot — read the plan, understand context, suggest and act, don't just CRUD.
712
718
  - Connected — you can reach into Roam, Drive, Sheets, Gmail, etc. to do real work, not just manage tasks.
719
+ - Taskflow is for projects with forward motion. Admin tasks, routines, and errands stay in Roam.
713
720
 
714
721
  MEMORY MANAGEMENT:
715
722
  Use tf_memory_read / tf_memory_update to maintain persistent context across sessions.
@@ -746,6 +753,7 @@ Core (always loaded):
746
753
  - `tf_*` tools — manage Taskflow projects, sections, tasks, search, and workspace views.
747
754
  - `tf_today` / `tf_focus` / `tf_unfocus` / `tf_move_focus` — daily focus list management.
748
755
  - `tf_create_goal` / `tf_update_goal` / `tf_goal_list` / `tf_goal_complete` / `tf_goal_reopen` / `tf_goal_remove` — goal management.
756
+ - `tf_workflow_list` / `tf_workflow_get` / `tf_workflow_save` — reusable project templates.
749
757
  - `read_file` / `list_dir` / `run_shell` — read files, browse directories, run shell commands (git, grep, etc.).
750
758
  - `notes_search` / `notes_read` — search and read Apple Notes (the user's phone-accessible capture tool).
751
759
  - `tf_repo_list` / `tf_repo_status` — check git status, recent commits, and open TODOs across connected repositories.
@@ -758,6 +766,13 @@ Deferred MCP servers (call `load_tools` with server_name first):
758
766
  - `notify` — Send notifications via Telegram or iMessage.
759
767
  - Other servers: {', '.join(s for s in deferred_tools if s not in ('roam-research', 'drive-mcp', 'gsheets-mcp', 'gmail-mcp', 'notify'))}
760
768
 
769
+ Sub-agent delegation:
770
+ - You have a `run_agent` tool that spawns a focused sub-agent with its own context window.
771
+ - Use it for intensive work that would bloat this conversation: code exploration, audits, file analysis, searching through notes or Roam pages, or research tasks that require many tool calls.
772
+ - The sub-agent is read-only. It cannot create, update, or delete tasks or projects. You handle all mutations based on its findings.
773
+ - Write detailed, specific instructions in the `task` field. The sub-agent has access to `read_file`, `list_dir`, git repo tools, read-only task tools, Apple Notes, and any MCP servers you've already loaded via `load_tools`. It does NOT have `run_shell`.
774
+ - If you need the sub-agent to access an MCP server such as Roam, call `load_tools` first, then spawn the agent.
775
+
761
776
  BEHAVIORAL GUIDELINES:
762
777
  - When the user is looking at a project, treat that project as the default context.
763
778
  - Reference project plans when suggesting next steps.
@@ -765,11 +780,15 @@ BEHAVIORAL GUIDELINES:
765
780
  - For planning-heavy requests, improve the plan markdown first, then extract tasks.
766
781
  - When the user mentions notes or ideas they captured, check Apple Notes or Roam.
767
782
  - When discussing code projects or repo work, use `tf_repo_status` to check current state before making recommendations.
783
+ - When the user wants to start a repeatable project type (video, thesis, blog post), check `tf_workflow_list` first. If a matching workflow exists, read it with `tf_workflow_get` and propose using it to scaffold the project. Wait for user confirmation before creating. Customize the plan from context; workflows are starting points, not rigid scripts.
784
+ - When a project reveals a repeatable process, suggest saving it as a workflow for next time.
785
+ - Workflows live in `data/workflows/` as markdown files. The user can also edit them directly.
768
786
  - Load deferred MCP servers proactively when the conversation clearly needs them.
769
787
  - Daily planning:
770
- When the user asks what to focus on today, or the Today view is empty, read goals first, survey overdue and due-soon work, consider carry-forward items, propose 3-5 high-leverage tasks with reasons, iterate with the user, then pin the agreed tasks with `tf_focus`.
788
+ When the user asks what to focus on today, or the Today view is empty: read goals first, survey active projects (in phase sequence), consider carry-forward items, ask about time constraints ("how much time do you have today?"), then propose 3-5 high-leverage tasks with reasons. Focus on what moves the needle most and what unblocks other work. Rough-estimate task duration in conversation to help scope. Iterate with the user, then pin the agreed tasks with `tf_focus`.
771
789
  - Prioritization heuristics:
772
- Overdue work with external stakeholders beats internal deadlines, unblockers beat isolated work, goal-aligned tasks beat dormant projects, quick wins are useful early, and focus lists should stay short.
790
+ Unblockers beat isolated work, goal-aligned tasks beat dormant projects, sequential dependencies matter (what's blocking the next phase?), quick wins are useful early, and focus lists should stay short. Be ruthless about cutting — if it doesn't fit the time available, defer it.
791
+ - When structuring projects, organize sections as phases/sequence (what to do first, second, third), NOT by category (API, Frontend, Database). Sequencing tells you what's next; categories are just filing.
773
792
  """.strip()
774
793
  return _trim_for_token_budget(prompt, PROMPT_TOKEN_BUDGET)
775
794
 
@@ -1071,6 +1090,24 @@ TF_TOOL_DEFINITIONS = [
1071
1090
  "description": "Load deferred MCP tools for a configured server from ~/.claude.json.",
1072
1091
  "input_schema": _schema({"server_name": {"type": "string"}}, ["server_name"]),
1073
1092
  },
1093
+ {
1094
+ "name": "run_agent",
1095
+ "description": "Spawn a read-only sub-agent to perform a focused task. The sub-agent gets its own context window and returns a structured response. Use this for intensive work that would bloat the main conversation: code audits, file exploration, note triage, research. The sub-agent cannot create, update, or delete tasks or projects; you handle mutations based on its findings.",
1096
+ "input_schema": _schema(
1097
+ {
1098
+ "task": {
1099
+ "type": "string",
1100
+ "description": "Detailed instructions for the sub-agent.",
1101
+ },
1102
+ "model": {
1103
+ "type": "string",
1104
+ "description": "Model override. Defaults to claude-sonnet-4-6.",
1105
+ "enum": ["claude-sonnet-4-6", "claude-opus-4-6"],
1106
+ },
1107
+ },
1108
+ required=["task"],
1109
+ ),
1110
+ },
1074
1111
  # --- Apple Notes tools ---
1075
1112
  {
1076
1113
  "name": "notes_search",
@@ -1104,6 +1141,24 @@ TF_TOOL_DEFINITIONS = [
1104
1141
  ["content"],
1105
1142
  ),
1106
1143
  },
1144
+ {
1145
+ "name": "tf_workflow_list",
1146
+ "description": "List available project workflow templates.",
1147
+ "input_schema": _schema({}),
1148
+ },
1149
+ {
1150
+ "name": "tf_workflow_get",
1151
+ "description": "Read a workflow template by slug.",
1152
+ "input_schema": _schema({"slug": {"type": "string"}}, ["slug"]),
1153
+ },
1154
+ {
1155
+ "name": "tf_workflow_save",
1156
+ "description": "Create or update a workflow template. Content is markdown with simple frontmatter (--- delimited, plain 'key: value' lines, no quoting or nesting). Required field: name.",
1157
+ "input_schema": _schema(
1158
+ {"slug": {"type": "string"}, "content": {"type": "string"}},
1159
+ ["slug", "content"],
1160
+ ),
1161
+ },
1107
1162
  # --- Filesystem tools ---
1108
1163
  {
1109
1164
  "name": "read_file",
@@ -1183,6 +1238,30 @@ MUTATING_TOOL_NAMES = {
1183
1238
  "tf_goal_reopen",
1184
1239
  "tf_goal_remove",
1185
1240
  }
1241
+ _SUB_AGENT_EXCLUDED_TOOLS: set[str] = {
1242
+ "run_agent",
1243
+ "run_shell",
1244
+ "load_tools",
1245
+ "tf_memory_read",
1246
+ "tf_memory_update",
1247
+ } | MUTATING_TOOL_NAMES
1248
+
1249
+ _SUB_AGENT_SYSTEM_PROMPT = (
1250
+ "You are a focused research assistant working on behalf of a project manager. "
1251
+ "You have read-only access to files, git repos, task data, and notes. "
1252
+ "You do NOT have shell access or the ability to modify tasks/projects.\n"
1253
+ "Complete the assigned task thoroughly and return a clear, structured response.\n\n"
1254
+ "Be concise — your output will be read by the orchestrator agent, not a human. "
1255
+ "If any tool call fails or returns unexpected data, note the issue clearly in "
1256
+ "your response rather than silently proceeding.\n\n"
1257
+ "Today's date: {date}"
1258
+ )
1259
+
1260
+ _SUB_AGENT_MAX_TURNS = 15
1261
+ _SUB_AGENT_TIMEOUT = int(os.getenv("SUB_AGENT_TIMEOUT", "300"))
1262
+ _SUB_AGENT_CLIENT_TIMEOUT = 90
1263
+ _SUB_AGENT_MAX_TOKENS = 32_000
1264
+ _SUB_AGENT_DEFAULT_MODEL = "claude-sonnet-4-6"
1186
1265
 
1187
1266
  mcp_manager = McpClientManager(
1188
1267
  allowed_servers=None,
@@ -1701,7 +1780,12 @@ async def load_tools_handler(tool_input, *, call_index=0):
1701
1780
 
1702
1781
  async with mcp_manager._lock:
1703
1782
  if server_name in mcp_manager._servers:
1704
- return {"status": "ok", "server_name": server_name, "already_loaded": True}, None
1783
+ return {
1784
+ "status": "ok",
1785
+ "server_name": server_name,
1786
+ "already_loaded": True,
1787
+ "_load_servers": [server_name],
1788
+ }, None
1705
1789
 
1706
1790
  config = _claude_config()
1707
1791
  mcp_servers = config.get("mcpServers")
@@ -1731,6 +1815,63 @@ async def load_tools_handler(tool_input, *, call_index=0):
1731
1815
  }, None
1732
1816
 
1733
1817
 
1818
+ def make_run_agent_handler(
1819
+ runner_ref: list[Any],
1820
+ local_tool_handlers: dict[str, Any],
1821
+ mcp_manager: McpClientManager,
1822
+ ):
1823
+ async def _handle_run_agent(
1824
+ tool_input: dict,
1825
+ *,
1826
+ call_index: int = 0,
1827
+ ) -> tuple[Any | None, dict[str, Any] | None]:
1828
+ runner = runner_ref[0]
1829
+ if runner is None:
1830
+ return None, {"code": "internal_error", "message": "Runner not initialized"}
1831
+
1832
+ task = tool_input.get("task", "")
1833
+ if not task or not isinstance(task, str):
1834
+ return None, {"code": "invalid_input", "message": "task is required"}
1835
+
1836
+ raw_model = tool_input.get("model")
1837
+ allowed = {"claude-sonnet-4-6", "claude-opus-4-6"}
1838
+ if raw_model is not None and raw_model not in allowed:
1839
+ return None, {"code": "invalid_input", "message": f"Invalid model: {raw_model}"}
1840
+
1841
+ system_prompt = _SUB_AGENT_SYSTEM_PROMPT.format(
1842
+ date=dt_date.today().isoformat()
1843
+ )
1844
+ effective_model = raw_model or _SUB_AGENT_DEFAULT_MODEL
1845
+
1846
+ sub_local = {
1847
+ name: handler
1848
+ for name, handler in local_tool_handlers.items()
1849
+ if name not in _SUB_AGENT_EXCLUDED_TOOLS
1850
+ }
1851
+
1852
+ sub_dispatcher = ToolDispatcher(
1853
+ mcp_client=mcp_manager,
1854
+ local_tool_handlers=sub_local,
1855
+ needs_approval=lambda _: False,
1856
+ )
1857
+
1858
+ result, error = await runner.spawn_sub_agent(
1859
+ task,
1860
+ model=effective_model,
1861
+ system_prompt=system_prompt,
1862
+ dispatcher=sub_dispatcher,
1863
+ excluded_tools=_SUB_AGENT_EXCLUDED_TOOLS,
1864
+ max_turns=_SUB_AGENT_MAX_TURNS,
1865
+ timeout=_SUB_AGENT_TIMEOUT,
1866
+ client_timeout=_SUB_AGENT_CLIENT_TIMEOUT,
1867
+ max_tokens=_SUB_AGENT_MAX_TOKENS,
1868
+ call_index=call_index,
1869
+ )
1870
+ return result, error
1871
+
1872
+ return _handle_run_agent
1873
+
1874
+
1734
1875
  def _run_osascript(script: str, timeout: float = 15.0) -> str:
1735
1876
  proc = subprocess.run(
1736
1877
  ["osascript", "-e", script],
@@ -1845,6 +1986,50 @@ async def tf_memory_update_handler(tool_input, *, call_index=0):
1845
1986
  return {"status": "ok", "path": str(MEMORY_FILE_PATH), "chars": len(content)}, None
1846
1987
 
1847
1988
 
1989
+ def _workflow_value_error(exc: ValueError):
1990
+ message = str(exc)
1991
+ if message.startswith("Invalid slug:") or message == "Frontmatter must include 'name' field":
1992
+ return _tool_error("invalid_input", message)
1993
+ if "exceeds size limit" in message or message == "Content exceeds limit (16 KB / 200 lines)":
1994
+ return _tool_error("too_large", message)
1995
+ return _tool_error("invalid_input", message)
1996
+
1997
+
1998
+ async def tf_workflow_list_handler(tool_input, *, call_index=0):
1999
+ del tool_input, call_index
2000
+ try:
2001
+ workflow_items = workflows.list_workflows()
2002
+ except OSError as exc:
2003
+ return _tool_error("file_error", f"Could not list workflows: {exc}")
2004
+ return {"workflows": workflow_items, "count": len(workflow_items)}, None
2005
+
2006
+
2007
+ async def tf_workflow_get_handler(tool_input, *, call_index=0):
2008
+ del call_index
2009
+ slug = str(tool_input.get("slug", ""))
2010
+ try:
2011
+ workflow_item = workflows.get_workflow(slug)
2012
+ except ValueError as exc:
2013
+ return _workflow_value_error(exc)
2014
+ except OSError as exc:
2015
+ return _tool_error("file_error", f"Could not read workflow '{slug}': {exc}")
2016
+ if workflow_item is None:
2017
+ return _tool_error("not_found", f"Workflow '{slug}' not found")
2018
+ return workflow_item, None
2019
+
2020
+
2021
+ async def tf_workflow_save_handler(tool_input, *, call_index=0):
2022
+ del call_index
2023
+ slug = str(tool_input.get("slug", ""))
2024
+ content = str(tool_input.get("content", ""))
2025
+ try:
2026
+ return workflows.save_workflow(slug, content), None
2027
+ except ValueError as exc:
2028
+ return _workflow_value_error(exc)
2029
+ except OSError as exc:
2030
+ return _tool_error("file_error", f"Could not write workflow '{slug}': {exc}")
2031
+
2032
+
1848
2033
  _READ_FILE_MAX_LINES = 2000
1849
2034
  _SHELL_TIMEOUT = 30
1850
2035
  _SHELL_MAX_OUTPUT = 50_000 # ~50 KB
@@ -1996,6 +2181,9 @@ LOCAL_TOOL_HANDLERS = {
1996
2181
  "notes_read": notes_read_handler,
1997
2182
  "tf_memory_read": tf_memory_read_handler,
1998
2183
  "tf_memory_update": tf_memory_update_handler,
2184
+ "tf_workflow_list": tf_workflow_list_handler,
2185
+ "tf_workflow_get": tf_workflow_get_handler,
2186
+ "tf_workflow_save": tf_workflow_save_handler,
1999
2187
  "read_file": read_file_handler,
2000
2188
  "list_dir": list_dir_handler,
2001
2189
  "run_shell": run_shell_handler,
@@ -2375,7 +2563,7 @@ def create_task(body: TaskCreate):
2375
2563
  def update_task(task_id: int, body: TaskUpdate):
2376
2564
  conn = _conn()
2377
2565
  fields = {}
2378
- for key in ("name", "notes", "assignee", "start_date", "due_date", "section_id"):
2566
+ for key in ("name", "notes", "assignee", "start_date", "due_date", "section_id", "position"):
2379
2567
  val = getattr(body, key)
2380
2568
  if val is not None:
2381
2569
  fields[key] = val
@@ -2389,6 +2577,23 @@ def update_task(task_id: int, body: TaskUpdate):
2389
2577
  return {"status": "ok"}
2390
2578
 
2391
2579
 
2580
+ @app.post("/api/tasks/reorder")
2581
+ def reorder_tasks(body: dict[str, Any]):
2582
+ """Batch update task positions. Body: { task_ids: [id1, id2, ...] }
2583
+ Sets position = index for each task in the array."""
2584
+ task_ids = body.get("task_ids", [])
2585
+ if not task_ids or not isinstance(task_ids, list):
2586
+ raise HTTPException(400, "task_ids must be a non-empty list")
2587
+ conn = _conn()
2588
+ try:
2589
+ for i, tid in enumerate(task_ids):
2590
+ db.update_task(conn, int(tid), position=i)
2591
+ finally:
2592
+ conn.close()
2593
+ invalidate_workspace_summary_cache()
2594
+ return {"status": "ok", "count": len(task_ids)}
2595
+
2596
+
2392
2597
  @app.post("/api/tasks/{task_id}/complete")
2393
2598
  def complete_task(task_id: int):
2394
2599
  conn = _conn()
@@ -2726,18 +2931,36 @@ async def chat(body: ChatRequest, request: Request):
2726
2931
  session_id = f"tf-{uuid4().hex[:8]}"
2727
2932
  log.info("chat_start | %s | view=%s messages=%d", session_id, body.context.view, len(messages))
2728
2933
  event_log = EventLog(session_id=session_id)
2934
+ request_handlers = dict(LOCAL_TOOL_HANDLERS)
2935
+ loaded_mcp_servers: set[str] = set()
2936
+ runner_ref: list[AgentRunner | None] = [None]
2937
+ request_handlers["run_agent"] = make_run_agent_handler(
2938
+ runner_ref,
2939
+ request_handlers,
2940
+ mcp_manager,
2941
+ )
2729
2942
  dispatcher = ToolDispatcher(
2730
2943
  mcp_client=mcp_manager,
2731
- local_tool_handlers=LOCAL_TOOL_HANDLERS,
2944
+ local_tool_handlers=request_handlers,
2945
+ needs_approval=lambda _: False,
2732
2946
  )
2947
+
2948
+ def get_tool_definitions():
2949
+ mcp_defs = mcp_manager.get_server_tool_definitions(loaded_mcp_servers)
2950
+ return TF_TOOL_DEFINITIONS + mcp_defs
2951
+
2733
2952
  runner = AgentRunner(
2734
2953
  dispatcher=dispatcher,
2735
2954
  event_log=event_log,
2736
2955
  session_id=event_log._session_id,
2737
2956
  auth_config=_anthropic_auth_config(),
2738
2957
  mcp_client=mcp_manager,
2739
- get_tool_definitions=lambda: TF_TOOL_DEFINITIONS + mcp_manager.get_tool_definitions(),
2958
+ loaded_mcp_servers=loaded_mcp_servers,
2959
+ get_tool_definitions=get_tool_definitions,
2960
+ client_timeout=90.0,
2961
+ per_turn_timeout=120.0,
2740
2962
  )
2963
+ runner_ref[0] = runner
2741
2964
  runner_task = asyncio.create_task(
2742
2965
  runner.run(
2743
2966
  messages=messages,
@@ -0,0 +1,138 @@
1
+ """Shared workflow template storage helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ WORKFLOWS_DIR = Path(__file__).resolve().parent.parent / "data" / "workflows"
10
+ MAX_BYTES = 16_384
11
+ MAX_LINES = 200
12
+ SLUG_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
13
+ MAX_SLUG_LEN = 60
14
+
15
+ _FRONTMATTER_LINE_RE = re.compile(r"^([a-zA-Z0-9_]+):\s*(.*)$")
16
+
17
+
18
+ def validate_slug(slug: str) -> str | None:
19
+ """Return an error message when a workflow slug is invalid."""
20
+ if not isinstance(slug, str):
21
+ return "must be kebab-case, 1-60 chars"
22
+ if not slug or len(slug) > MAX_SLUG_LEN or not SLUG_RE.fullmatch(slug):
23
+ return "must be kebab-case, 1-60 chars"
24
+ return None
25
+
26
+
27
+ def parse_frontmatter(content: str) -> dict[str, str]:
28
+ """Extract recognized frontmatter fields from markdown content."""
29
+ metadata = {"name": "", "description": ""}
30
+ lines = content.splitlines()
31
+ if not lines or lines[0] != "---":
32
+ return metadata
33
+
34
+ closing_index = None
35
+ for index, line in enumerate(lines[1:], start=1):
36
+ if line == "---":
37
+ closing_index = index
38
+ break
39
+ if closing_index is None:
40
+ return metadata
41
+
42
+ for line in lines[1:closing_index]:
43
+ match = _FRONTMATTER_LINE_RE.match(line)
44
+ if not match:
45
+ continue
46
+ key, value = match.groups()
47
+ if key in metadata:
48
+ metadata[key] = value.strip()
49
+ return metadata
50
+
51
+
52
+ def list_workflows() -> list[dict[str, str]]:
53
+ """Return workflow metadata for all valid workflow files."""
54
+ if not WORKFLOWS_DIR.is_dir():
55
+ return []
56
+
57
+ workflow_items = []
58
+ for path in sorted(WORKFLOWS_DIR.glob("*.md")):
59
+ try:
60
+ if path.stat().st_size > MAX_BYTES:
61
+ continue
62
+ content = path.read_text(encoding="utf-8", errors="replace")
63
+ except OSError:
64
+ continue
65
+ slug = path.stem
66
+ if validate_slug(slug):
67
+ continue
68
+ if _line_count(content) > MAX_LINES:
69
+ continue
70
+ workflow_items.append(_build_workflow_metadata(slug, content))
71
+ return workflow_items
72
+
73
+
74
+ def get_workflow(slug: str) -> dict[str, str] | None:
75
+ """Return workflow metadata plus raw markdown content for a slug."""
76
+ error = validate_slug(slug)
77
+ if error:
78
+ raise ValueError(f"Invalid slug: {error}")
79
+
80
+ path = WORKFLOWS_DIR / f"{slug}.md"
81
+ try:
82
+ size = path.stat().st_size
83
+ except FileNotFoundError:
84
+ return None
85
+ if size > MAX_BYTES:
86
+ raise ValueError(f"Workflow '{slug}' exceeds size limit")
87
+
88
+ content = path.read_text(encoding="utf-8", errors="replace")
89
+ if _line_count(content) > MAX_LINES:
90
+ raise ValueError(f"Workflow '{slug}' exceeds size limit")
91
+ return _build_workflow_metadata(slug, content) | {"content": content}
92
+
93
+
94
+ def save_workflow(slug: str, content: str) -> dict[str, str | int]:
95
+ """Create or replace a workflow markdown file atomically."""
96
+ error = validate_slug(slug)
97
+ if error:
98
+ raise ValueError(f"Invalid slug: {error}")
99
+
100
+ if len(content.encode("utf-8")) > MAX_BYTES or _line_count(content) > MAX_LINES:
101
+ raise ValueError("Content exceeds limit (16 KB / 200 lines)")
102
+
103
+ metadata = parse_frontmatter(content)
104
+ if not metadata.get("name"):
105
+ raise ValueError("Frontmatter must include 'name' field")
106
+
107
+ WORKFLOWS_DIR.mkdir(parents=True, exist_ok=True)
108
+ path = WORKFLOWS_DIR / f"{slug}.md"
109
+ tmp_path: Path | None = None
110
+ try:
111
+ with tempfile.NamedTemporaryFile(
112
+ "w",
113
+ encoding="utf-8",
114
+ dir=WORKFLOWS_DIR,
115
+ prefix=f".{slug}-",
116
+ suffix=".tmp",
117
+ delete=False,
118
+ ) as tmp_file:
119
+ tmp_file.write(content)
120
+ tmp_path = Path(tmp_file.name)
121
+ tmp_path.replace(path)
122
+ except OSError:
123
+ if tmp_path is not None:
124
+ tmp_path.unlink(missing_ok=True)
125
+ raise
126
+
127
+ return {"status": "ok", "slug": slug, "path": str(path), "chars": len(content)}
128
+
129
+
130
+ def _line_count(content: str) -> int:
131
+ return len(content.splitlines())
132
+
133
+
134
+ def _build_workflow_metadata(slug: str, content: str) -> dict[str, str]:
135
+ metadata = parse_frontmatter(content)
136
+ name = metadata.get("name") or slug.replace("-", " ").title()
137
+ description = metadata.get("description") or ""
138
+ return {"slug": slug, "name": name, "description": description}
@@ -613,6 +613,17 @@ button { cursor: pointer; }
613
613
  }
614
614
  .task-row:hover { background: var(--bg-surface); }
615
615
  .task-row.selected { background: var(--bg-surface-2); }
616
+ .task-row[draggable="true"] { cursor: grab; }
617
+ .task-row.dragging { opacity: 0.4; }
618
+ .task-row.drag-over { border-top: 2px solid var(--accent); margin-top: -2px; }
619
+ .t-drag {
620
+ flex-shrink: 0; width: 14px; margin-top: 2px;
621
+ color: var(--text-muted); font-size: 10px; cursor: grab;
622
+ opacity: 0; transition: opacity var(--transition);
623
+ user-select: none;
624
+ }
625
+ .task-row:hover .t-drag { opacity: 0.6; }
626
+ .t-drag:hover { opacity: 1 !important; }
616
627
 
617
628
  .t-check {
618
629
  width: 16px; height: 16px; border: 1.5px solid var(--border);
@@ -1154,6 +1165,13 @@ button { cursor: pointer; }
1154
1165
  font-size: 11px;
1155
1166
  color: var(--accent);
1156
1167
  }
1168
+ .chat-tool-hint {
1169
+ color: var(--text-muted);
1170
+ font-family: var(--font-mono);
1171
+ font-size: 10px;
1172
+ margin-left: 4px;
1173
+ opacity: 0.7;
1174
+ }
1157
1175
  .chat-tool-state {
1158
1176
  font-size: 10px;
1159
1177
  text-transform: uppercase;
@@ -1162,6 +1180,28 @@ button { cursor: pointer; }
1162
1180
  }
1163
1181
  .chat-tool-card.complete .chat-tool-state { color: var(--green); }
1164
1182
  .chat-tool-card.error .chat-tool-state { color: var(--red); }
1183
+ .chat-tool-group {
1184
+ width: 100%;
1185
+ background: var(--bg-surface-2);
1186
+ border: 1px solid var(--border-subtle);
1187
+ border-radius: 12px;
1188
+ overflow: hidden;
1189
+ }
1190
+ .chat-tool-group summary {
1191
+ list-style: none;
1192
+ display: flex;
1193
+ align-items: center;
1194
+ justify-content: space-between;
1195
+ gap: 8px;
1196
+ padding: 9px 12px;
1197
+ cursor: pointer;
1198
+ font-size: 12px;
1199
+ color: var(--text-secondary);
1200
+ }
1201
+ .chat-tool-group summary::-webkit-details-marker { display: none; }
1202
+ .chat-tool-group .chat-tool-state.error { color: var(--red); }
1203
+ .chat-tool-group-body { padding: 4px 8px 8px; }
1204
+ .chat-tool-group-body .tool { margin-bottom: 4px; }
1165
1205
  .chat-tool-body {
1166
1206
  padding: 0 12px 12px;
1167
1207
  border-top: 1px solid var(--border-subtle);
@@ -1585,17 +1625,21 @@ function renderSidebar() {
1585
1625
  }
1586
1626
  html += '</div>'
1587
1627
  } else {
1628
+ const collapsiblePhases = new Set(['done', 'reference'])
1588
1629
  let first = true
1589
1630
  for (const ph of phaseOrder) {
1590
1631
  const projs = active.filter(p => p.phase === ph)
1591
1632
  if (!projs.length) continue
1633
+ const collapsible = collapsiblePhases.has(ph)
1634
+ const collapseAttr = collapsible ? ' style="cursor:pointer;user-select:none" onclick="this.nextElementSibling.classList.toggle(\'collapsed\')"' : ''
1635
+ const countHint = collapsible ? ` <span style="opacity:.5">${projs.length}</span>` : ''
1592
1636
  if (first) {
1593
- html += `<div class="sb-group-label">${phaseLabels[ph] || ph} <span class="sb-group-toggle" onclick="event.stopPropagation();setSidebarSort('recent')" title="Sort by recent">↕</span></div>`
1637
+ html += `<div class="sb-group-label"${collapseAttr}>${phaseLabels[ph] || ph}${countHint} <span class="sb-group-toggle" onclick="event.stopPropagation();setSidebarSort('recent')" title="Sort by recent">↕</span></div>`
1594
1638
  first = false
1595
1639
  } else {
1596
- html += `<div class="sb-group-label">${phaseLabels[ph] || ph}</div>`
1640
+ html += `<div class="sb-group-label"${collapseAttr}>${phaseLabels[ph] || ph}${countHint}</div>`
1597
1641
  }
1598
- html += '<div class="sb-projects">'
1642
+ html += `<div class="sb-projects${collapsible ? ' collapsed' : ''}">`
1599
1643
  for (const p of projs) {
1600
1644
  html += `<div class="sb-proj ${p.id === S.projectId ? 'active' : ''}" onclick="goProject(${p.id})">
1601
1645
  <span class="phase-dot ph-${p.phase}"></span>
@@ -1943,6 +1987,62 @@ function renderProject() {
1943
1987
  }
1944
1988
 
1945
1989
  c.innerHTML = html
1990
+ initDragReorder(c)
1991
+ }
1992
+
1993
+ /* ---- Drag-to-reorder tasks ---- */
1994
+ let _dragId = null
1995
+ function initDragReorder(container) {
1996
+ container.querySelectorAll('.task-row[draggable="true"]').forEach(row => {
1997
+ row.addEventListener('dragstart', e => {
1998
+ _dragId = +row.dataset.id
1999
+ row.classList.add('dragging')
2000
+ e.dataTransfer.effectAllowed = 'move'
2001
+ e.dataTransfer.setData('text/plain', row.dataset.id)
2002
+ })
2003
+ row.addEventListener('dragend', () => {
2004
+ _dragId = null
2005
+ row.classList.remove('dragging')
2006
+ container.querySelectorAll('.drag-over').forEach(r => r.classList.remove('drag-over'))
2007
+ })
2008
+ row.addEventListener('dragover', e => {
2009
+ e.preventDefault()
2010
+ e.dataTransfer.dropEffect = 'move'
2011
+ if (+row.dataset.id === _dragId) return
2012
+ container.querySelectorAll('.drag-over').forEach(r => r.classList.remove('drag-over'))
2013
+ row.classList.add('drag-over')
2014
+ })
2015
+ row.addEventListener('dragleave', () => {
2016
+ row.classList.remove('drag-over')
2017
+ })
2018
+ row.addEventListener('drop', async e => {
2019
+ e.preventDefault()
2020
+ row.classList.remove('drag-over')
2021
+ const targetId = +row.dataset.id
2022
+ if (!_dragId || _dragId === targetId) return
2023
+ // Find the spec-body (container) for this row
2024
+ const body = row.closest('.spec-body')
2025
+ if (!body) return
2026
+ const rows = [...body.querySelectorAll('.task-row[draggable="true"]')]
2027
+ const ids = rows.map(r => +r.dataset.id)
2028
+ const fromIdx = ids.indexOf(_dragId)
2029
+ const toIdx = ids.indexOf(targetId)
2030
+ if (fromIdx < 0 || toIdx < 0) return
2031
+ // Reorder: remove from old position, insert before target
2032
+ ids.splice(fromIdx, 1)
2033
+ ids.splice(toIdx > fromIdx ? toIdx - 1 : toIdx, 0, _dragId)
2034
+ // Optimistic DOM reorder
2035
+ const draggedRow = body.querySelector(`.task-row[data-id="${_dragId}"]`)
2036
+ if (draggedRow) body.insertBefore(draggedRow, row)
2037
+ // Persist
2038
+ try {
2039
+ await api('/api/tasks/reorder', { method: 'POST', body: JSON.stringify({ task_ids: ids }) })
2040
+ } catch (err) {
2041
+ toast('Reorder failed')
2042
+ await refreshProject()
2043
+ }
2044
+ })
2045
+ })
1946
2046
  }
1947
2047
 
1948
2048
  function filteredTasks() {
@@ -1959,8 +2059,10 @@ function setFilter(f) {
1959
2059
  function taskRow(t) {
1960
2060
  const done = t.status === 'completed'
1961
2061
  const dc = dueClass(t.due_date, t.status)
2062
+ const draggable = S.view === 'project' && !done
1962
2063
  return `<div class="task-row ${done ? 'completed' : ''} ${t.id === S.selTaskId ? 'selected' : ''}"
1963
- data-id="${t.id}" onclick="selTask(${t.id})">
2064
+ data-id="${t.id}" ${draggable ? 'draggable="true"' : ''} onclick="selTask(${t.id})">
2065
+ ${draggable ? '<div class="t-drag" onmousedown="event.stopPropagation()">⠿</div>' : ''}
1964
2066
  <div class="t-check ${done ? 'done' : ''}"
1965
2067
  onclick="event.stopPropagation();toggleTask(${t.id},'${t.status}')"></div>
1966
2068
  <div class="t-info">
@@ -2593,6 +2695,14 @@ function updateStreamingAssistant(text) {
2593
2695
  scrollChatToBottom()
2594
2696
  }
2595
2697
 
2698
+ function _toolHint(name, input) {
2699
+ if (!input || typeof input !== 'object') return ''
2700
+ const v = input.project_id || input.task_id || input.query || input.name || input.text || input.repo || input.path || input.command || input.server_name || ''
2701
+ if (!v) return ''
2702
+ const s = String(v)
2703
+ return s.length > 40 ? s.slice(0, 40) + '…' : s
2704
+ }
2705
+
2596
2706
  function appendToolCard(toolCallId, toolName, toolInput) {
2597
2707
  clearChatEmptyState()
2598
2708
  const row = document.createElement('div')
@@ -2600,9 +2710,11 @@ function appendToolCard(toolCallId, toolName, toolInput) {
2600
2710
  const details = document.createElement('details')
2601
2711
  details.className = 'chat-tool-card'
2602
2712
  details.open = true
2713
+ const hint = _toolHint(toolName, toolInput)
2714
+ const hintHtml = hint ? ` <span class="chat-tool-hint">${esc(hint)}</span>` : ''
2603
2715
  details.innerHTML = `
2604
2716
  <summary>
2605
- <span class="chat-tool-name">${esc(toolName)}</span>
2717
+ <span class="chat-tool-name">${esc(toolName)}${hintHtml}</span>
2606
2718
  <span class="chat-tool-state">running</span>
2607
2719
  </summary>
2608
2720
  <div class="chat-tool-body">
@@ -2967,6 +3079,35 @@ function finishChatStream(assistantText) {
2967
3079
  S.chat.currentAssistantEl = null
2968
3080
  }
2969
3081
 
3082
+ // Collapse individual tool cards into a single summary row above the assistant message
3083
+ const chatMsgs = document.getElementById('chat-messages')
3084
+ const toolRows = Array.from(chatMsgs.querySelectorAll('.chat-row.tool'))
3085
+ if (toolRows.length > 0) {
3086
+ const uniqueTools = [...new Set(S.chat.turnTools.map(t => t.name.replace(/^tf_/, '')))]
3087
+ const label = uniqueTools.length <= 3 ? uniqueTools.join(', ') : `${toolRows.length} tools`
3088
+ const groupRow = document.createElement('div')
3089
+ groupRow.className = 'chat-row tool'
3090
+ const groupDetails = document.createElement('details')
3091
+ groupDetails.className = 'chat-tool-group'
3092
+ const hasError = S.chat.turnTools.some(t => t.error)
3093
+ groupDetails.innerHTML = `<summary><span class="chat-tool-name">${esc(label)}</span> <span class="chat-tool-state ${hasError ? 'error' : ''}">${hasError ? 'errors' : 'done'}</span></summary>`
3094
+ const groupBody = document.createElement('div')
3095
+ groupBody.className = 'chat-tool-group-body'
3096
+ toolRows.forEach(tr => {
3097
+ tr.classList.remove('chat-row')
3098
+ groupBody.appendChild(tr)
3099
+ })
3100
+ groupDetails.appendChild(groupBody)
3101
+ groupRow.appendChild(groupDetails)
3102
+ // Insert before the assistant bubble
3103
+ const assistantRow = S.chat.currentAssistantEl ? S.chat.currentAssistantEl.closest('.chat-row') : null
3104
+ if (assistantRow) {
3105
+ chatMsgs.insertBefore(groupRow, assistantRow)
3106
+ } else {
3107
+ chatMsgs.appendChild(groupRow)
3108
+ }
3109
+ }
3110
+
2970
3111
  S.chat.messages.push({ role: 'assistant', content: fullContent })
2971
3112
  saveChatMessages()
2972
3113
  resetChatTurnState()
File without changes
File without changes
File without changes