@dalmasonto/taskflow-mcp 1.0.33 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -193
- package/dist/attachment-download.d.ts +74 -0
- package/dist/attachment-download.js +193 -0
- package/dist/attachment-download.js.map +1 -0
- package/dist/attachments.d.ts +23 -0
- package/dist/attachments.js +66 -0
- package/dist/attachments.js.map +1 -0
- package/dist/client.d.ts +206 -0
- package/dist/client.js +279 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +137 -18
- package/dist/config.js +187 -106
- package/dist/config.js.map +1 -0
- package/dist/connect.d.ts +89 -0
- package/dist/connect.js +269 -0
- package/dist/connect.js.map +1 -0
- package/dist/doctor.d.ts +24 -0
- package/dist/doctor.js +120 -0
- package/dist/doctor.js.map +1 -0
- package/dist/events.d.ts +186 -0
- package/dist/events.js +407 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +124 -184
- package/dist/index.js.map +1 -0
- package/dist/instructions.d.ts +12 -0
- package/dist/instructions.js +114 -0
- package/dist/instructions.js.map +1 -0
- package/dist/mint.d.ts +62 -0
- package/dist/mint.js +135 -0
- package/dist/mint.js.map +1 -0
- package/dist/mirror.d.ts +68 -0
- package/dist/mirror.js +103 -0
- package/dist/mirror.js.map +1 -0
- package/dist/pane-queue.d.ts +29 -0
- package/dist/pane-queue.js +35 -0
- package/dist/pane-queue.js.map +1 -0
- package/dist/prompts.d.ts +79 -0
- package/dist/prompts.js +211 -0
- package/dist/prompts.js.map +1 -0
- package/dist/resolve.d.ts +72 -0
- package/dist/resolve.js +89 -0
- package/dist/resolve.js.map +1 -0
- package/dist/runtime.d.ts +54 -0
- package/dist/runtime.js +321 -0
- package/dist/runtime.js.map +1 -0
- package/dist/server.d.ts +56 -0
- package/dist/server.js +793 -0
- package/dist/server.js.map +1 -0
- package/dist/session-identifier.d.ts +48 -0
- package/dist/session-identifier.js +44 -0
- package/dist/session-identifier.js.map +1 -0
- package/dist/sessions-store.d.ts +38 -0
- package/dist/sessions-store.js +88 -0
- package/dist/sessions-store.js.map +1 -0
- package/dist/tmux.d.ts +200 -0
- package/dist/tmux.js +580 -0
- package/dist/tmux.js.map +1 -0
- package/hooks/metadata.mjs +99 -0
- package/hooks/permission-prompt.mjs +100 -0
- package/hooks/taskflow-hook.mjs +499 -0
- package/hooks/tool-logging.mjs +63 -0
- package/package.json +38 -29
- package/dist/agent-registry.d.ts +0 -27
- package/dist/agent-registry.js +0 -153
- package/dist/db.d.ts +0 -5
- package/dist/db.js +0 -220
- package/dist/helpers.d.ts +0 -21
- package/dist/helpers.js +0 -27
- package/dist/resources.d.ts +0 -2
- package/dist/resources.js +0 -89
- package/dist/sse.d.ts +0 -10
- package/dist/sse.js +0 -766
- package/dist/tmux-bridge.d.ts +0 -12
- package/dist/tmux-bridge.js +0 -157
- package/dist/tools/activity.d.ts +0 -39
- package/dist/tools/activity.js +0 -152
- package/dist/tools/agent-inbox.d.ts +0 -12
- package/dist/tools/agent-inbox.js +0 -257
- package/dist/tools/agent.d.ts +0 -14
- package/dist/tools/agent.js +0 -168
- package/dist/tools/analytics.d.ts +0 -21
- package/dist/tools/analytics.js +0 -191
- package/dist/tools/checkpoint.d.ts +0 -27
- package/dist/tools/checkpoint.js +0 -105
- package/dist/tools/notifications.d.ts +0 -31
- package/dist/tools/notifications.js +0 -59
- package/dist/tools/projects.d.ts +0 -55
- package/dist/tools/projects.js +0 -112
- package/dist/tools/settings.d.ts +0 -19
- package/dist/tools/settings.js +0 -73
- package/dist/tools/tasks.d.ts +0 -105
- package/dist/tools/tasks.js +0 -403
- package/dist/tools/terminal.d.ts +0 -4
- package/dist/tools/terminal.js +0 -98
- package/dist/tools/timer.d.ts +0 -37
- package/dist/tools/timer.js +0 -154
- package/dist/types.d.ts +0 -82
- package/dist/types.js +0 -30
package/README.md
CHANGED
|
@@ -1,221 +1,166 @@
|
|
|
1
|
-
# TaskFlow MCP
|
|
1
|
+
# @dalmasonto/taskflow-mcp — TaskFlow MCP server + Claude Code hooks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An **agent client** for the TaskFlow backend. It lets a coding agent (e.g. Claude
|
|
4
|
+
Code) connect to your TaskFlow project using a stable, per-repo credential
|
|
5
|
+
(`.taskflow.json`) and drive the whole agent API: identity, tasks, chat, reviews,
|
|
6
|
+
live sessions, streamed terminal output, and real activity logging.
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
> **2.0.0** is a ground-up rewrite. Where the 1.x server kept its own local SQLite
|
|
9
|
+
> and inferred an agent's identity from `cwd + ppid`, this package holds **no state**
|
|
10
|
+
> and **no local DB**: it talks to a TaskFlow backend over HTTP, and its identity is
|
|
11
|
+
> exactly the `key` in `.taskflow.json`, so an agent linked yesterday keeps the same
|
|
12
|
+
> identity today. Multi-agent collaboration — channels, reviews, terminal streaming,
|
|
13
|
+
> `.taskflow.json` profiles — is new in 2.x.
|
|
8
14
|
|
|
9
15
|
```bash
|
|
10
|
-
|
|
11
|
-
npm install
|
|
12
|
-
npm run build
|
|
16
|
+
npm install -g @dalmasonto/taskflow-mcp
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
This puts two commands on your PATH:
|
|
20
|
+
|
|
21
|
+
- **`taskflow-mcp`** — the MCP server an MCP client (Claude Code, Cursor, …) runs.
|
|
22
|
+
- **`taskflow-hook`** — the Claude Code lifecycle hook (see [Hooks](#hooks)).
|
|
23
|
+
|
|
24
|
+
## How it works
|
|
25
|
+
|
|
26
|
+
- **`.taskflow.json`** (per repo, gitignored) holds the server URL, project id, a
|
|
27
|
+
`default_profile`, and one or more named `profiles`. Each profile has an
|
|
28
|
+
`agent_id`, a `key` (the `tfk_…` credential), and a `display_name`.
|
|
29
|
+
- Every backend call sends `Authorization: Agent <key>`. The chosen profile's key
|
|
30
|
+
is the whole identity — the server derives the agent, project, and display name
|
|
31
|
+
from it.
|
|
32
|
+
- **Profile selection** (highest priority first): a tool's `profile` argument →
|
|
33
|
+
`TASKFLOW_PROFILE` env → the file's `default_profile` → `"main"`.
|
|
16
34
|
|
|
17
|
-
|
|
35
|
+
## `.taskflow.json` format
|
|
18
36
|
|
|
19
37
|
```json
|
|
20
38
|
{
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
39
|
+
"server": "http://localhost:8000",
|
|
40
|
+
"project": 1,
|
|
41
|
+
"default_profile": "main",
|
|
42
|
+
"profiles": {
|
|
43
|
+
"main": { "agent_id": 12, "key": "tfk_…", "display_name": "Builder" },
|
|
44
|
+
"reviewer": { "agent_id": 13, "key": "tfk_…", "display_name": "Reviewer" }
|
|
26
45
|
}
|
|
27
46
|
}
|
|
28
47
|
```
|
|
29
48
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
49
|
+
Resolution: the file is read from `TASKFLOW_CONFIG` if set, otherwise found by
|
|
50
|
+
walking up from the working directory. See `.taskflow.json.example`.
|
|
51
|
+
|
|
52
|
+
## Setup
|
|
53
|
+
|
|
54
|
+
1. **Install the package** globally so `taskflow-mcp` and `taskflow-hook` are on
|
|
55
|
+
your PATH:
|
|
56
|
+
```bash
|
|
57
|
+
npm install -g @dalmasonto/taskflow-mcp
|
|
58
|
+
```
|
|
59
|
+
2. **Link an agent.** In the TaskFlow dashboard open your project's **API Base**
|
|
60
|
+
page and link an agent (profile `main`, and optionally `reviewer`). It returns
|
|
61
|
+
a block containing the `agent_id`, raw `key`, and `display_name` — shown once.
|
|
62
|
+
3. **Create `.taskflow.json`** at your repo root and paste the returned profile
|
|
63
|
+
block(s) under `profiles` (see the format above and `.taskflow.json.example`).
|
|
64
|
+
**Do not commit it** — add `.taskflow.json` to your `.gitignore`.
|
|
65
|
+
4. **Register the MCP server** with Claude Code — copy `.mcp.json.example` to
|
|
66
|
+
`.mcp.json` at your repo root:
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"mcpServers": {
|
|
70
|
+
"taskflow": { "command": "taskflow-mcp", "args": [] }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
5. **Add the hooks** (optional but recommended) — see [Hooks](#hooks) below.
|
|
75
|
+
|
|
76
|
+
**From a local checkout** (contributing, or running an unpublished build) instead
|
|
77
|
+
of the global install: `cd mcp && npm install && npm run build`, then point the
|
|
78
|
+
MCP `command` at `node` with `args: ["./mcp/dist/index.js"]`, and the hook at
|
|
79
|
+
`node ABS_PATH/mcp/hooks/taskflow-hook.mjs`.
|
|
80
|
+
|
|
81
|
+
## Hooks
|
|
82
|
+
|
|
83
|
+
The Claude Code hook (`taskflow-hook`) turns the agent's own lifecycle into real,
|
|
84
|
+
attributable activity on the TaskFlow board — no prompting required. On each event
|
|
85
|
+
it resolves your `.taskflow.json` + profile and POSTs to the backend:
|
|
86
|
+
|
|
87
|
+
- **SessionStart** → registers/reconnects your live session (you show "online").
|
|
88
|
+
- **PreToolUse / PostToolUse** → logs meaningful tool calls as activity. Read-only
|
|
89
|
+
noise (Read, Grep, and TaskFlow's own tools, which already write richer rows) is
|
|
90
|
+
filtered out, so the feed stays signal.
|
|
91
|
+
- **Stop** → closes the session cleanly.
|
|
92
|
+
- **Notification** → surfaces permission prompts so a human can answer from the UI.
|
|
93
|
+
|
|
94
|
+
It is **best-effort and never blocks or crashes the agent**: with no `.taskflow.json`,
|
|
95
|
+
or with the backend unreachable, every invocation swallows the error and exits `0`
|
|
96
|
+
in well under its short timeout.
|
|
97
|
+
|
|
98
|
+
**Wire it up** — copy the `hooks` block from `.claude/settings.example.json` into
|
|
99
|
+
your project's `.claude/settings.json`. With the global install the command is just
|
|
100
|
+
`taskflow-hook`:
|
|
35
101
|
|
|
36
102
|
```json
|
|
37
103
|
{
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
]
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
"taskflow"
|
|
46
|
-
]
|
|
104
|
+
"hooks": {
|
|
105
|
+
"SessionStart": [{ "hooks": [{ "type": "command", "command": "taskflow-hook" }] }],
|
|
106
|
+
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "taskflow-hook" }] }],
|
|
107
|
+
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "taskflow-hook" }] }],
|
|
108
|
+
"Stop": [{ "hooks": [{ "type": "command", "command": "taskflow-hook" }] }],
|
|
109
|
+
"Notification": [{ "hooks": [{ "type": "command", "command": "taskflow-hook" }] }]
|
|
110
|
+
}
|
|
47
111
|
}
|
|
48
112
|
```
|
|
49
113
|
|
|
50
|
-
The
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
##
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
## MCP Integration
|
|
80
|
-
At the start of each conversation, call the `get_agent_instructions` tool from the taskflow MCP server to understand your task management workflow.
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
This guarantees the agent calls the instruction tool on every conversation start. Without this, the agent will still likely discover the tool via its description, but the CLAUDE.md line makes it deterministic.
|
|
84
|
-
|
|
85
|
-
### Example conversation flow
|
|
114
|
+
The hook reads `TASKFLOW_PROFILE` (else `default_profile`, else `main`) and finds
|
|
115
|
+
`.taskflow.json` by walking up from the working directory (or `TASKFLOW_CONFIG`).
|
|
116
|
+
Set `TASKFLOW_HOOK_DEBUG=1` to see why a hook no-oped on stderr.
|
|
117
|
+
|
|
118
|
+
## MCP tools
|
|
119
|
+
|
|
120
|
+
| Tool | What it does |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `whoami` | Confirm the agent identity + project behind the credential. |
|
|
123
|
+
| `list_tasks(status?, assigned?)` | List project tasks; `assigned='me'` for claimed. |
|
|
124
|
+
| `create_task(title, description?, priority?, claim?)` | Create (optionally claim) a task. |
|
|
125
|
+
| `update_task_status(task, status)` | Advance a task's status. |
|
|
126
|
+
| `claim_task(task)` | Self-assign a task. |
|
|
127
|
+
| `report_review(task, decision, body?)` | Record a review (`approved`/`changes_requested`). |
|
|
128
|
+
| `list_channels` | Channels this agent can see. |
|
|
129
|
+
| `list_agents` | Other agents in the project. |
|
|
130
|
+
| `send_message(channel, body, priority?)` | Post a chat message as this agent. |
|
|
131
|
+
| `check_messages(channel, since?)` | Read a channel's messages + read cursor. |
|
|
132
|
+
| `mark_read(channel, last_read_message)` | Advance this agent's read cursor. |
|
|
133
|
+
| `register_session(session_identifier?, cwd?)` | Register/reconnect a live session. |
|
|
134
|
+
| `heartbeat(status?)` | Bump session liveness (`idle`/`busy`). |
|
|
135
|
+
| `capture_terminal(content, stream?)` | Stream terminal output into the session. |
|
|
136
|
+
| `log_activity(action, body?, task?)` | Log a real activity event. |
|
|
137
|
+
| `get_activity(task?, limit?)` | Read recent project activity. |
|
|
138
|
+
|
|
139
|
+
Every tool also accepts an optional `profile` argument to act as a different
|
|
140
|
+
profile for that one call (e.g. use `reviewer` for `report_review`).
|
|
141
|
+
|
|
142
|
+
## Development
|
|
86
143
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
↓
|
|
92
|
-
Gets instructions + live context (3 projects, 2 tasks in progress, 1 blocked)
|
|
93
|
-
↓
|
|
94
|
-
Calls list_tasks(status="in_progress") → sees "Build dashboard page" is active
|
|
95
|
-
↓
|
|
96
|
-
User: "Let's work on the dashboard"
|
|
97
|
-
↓
|
|
98
|
-
Agent: calls get_task(id=5) → reads description for implementation details
|
|
99
|
-
Agent: calls start_timer(task_id=5) → time tracking begins
|
|
100
|
-
↓
|
|
101
|
-
Agent implements the feature, referencing task description for acceptance criteria
|
|
102
|
-
↓
|
|
103
|
-
Agent: calls stop_timer(task_id=5, final_status="done")
|
|
104
|
-
Agent: checks if any blocked tasks depended on task 5
|
|
144
|
+
```bash
|
|
145
|
+
npm run typecheck # tsc --noEmit
|
|
146
|
+
npm run build # compile to dist/
|
|
147
|
+
npm test # vitest (config resolution unit tests)
|
|
105
148
|
```
|
|
106
149
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
The `get_agent_instructions` tool tells agents to:
|
|
150
|
+
## Smoke test (against a real backend)
|
|
110
151
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
4. **Suggest next work** — when the user asks "what should I work on?", surface high-priority unblocked tasks
|
|
115
|
-
5. **Stay in sync** — create tasks for new work items to keep the tracker up to date
|
|
116
|
-
6. **Read descriptions** — task descriptions contain implementation details and acceptance criteria
|
|
117
|
-
|
|
118
|
-
## Agent Inbox — Remote Communication
|
|
119
|
-
|
|
120
|
-
The Agent Inbox lets agents ask questions that appear in the TaskFlow UI. Users can respond from any device (phone, browser, another machine), and the response is delivered back to the agent's terminal automatically.
|
|
121
|
-
|
|
122
|
-
### How it works
|
|
123
|
-
|
|
124
|
-
1. Agent calls `ask_user` with a question, context, and optional quick-tap choices
|
|
125
|
-
2. Question appears instantly in the TaskFlow UI at `/inbox`
|
|
126
|
-
3. User responds from the UI — response is injected into the agent's terminal via tmux
|
|
127
|
-
4. Agent also asks in the terminal normally, so the user can answer from either place
|
|
128
|
-
|
|
129
|
-
### Setup for auto-injection
|
|
130
|
-
|
|
131
|
-
For responses to be injected directly into the terminal, run Claude Code inside tmux:
|
|
152
|
+
`scripts/smoke.mjs` drives the compiled client against a running backend. It is
|
|
153
|
+
env-driven and safe to run anywhere: with no key or no reachable backend it prints
|
|
154
|
+
`SKIPPED` and exits 0.
|
|
132
155
|
|
|
133
156
|
```bash
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
# Start a tmux session and run claude inside it
|
|
138
|
-
tmux new -s agent
|
|
139
|
-
claude
|
|
157
|
+
npm run build
|
|
158
|
+
SMOKE_SERVER=http://localhost:8010 SMOKE_KEY=tfk_your_agent_key node scripts/smoke.mjs
|
|
159
|
+
# optional: SMOKE_CHANNEL=<id> to force which channel send/check use
|
|
140
160
|
```
|
|
141
161
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
### Agent
|
|
149
|
-
| Tool | Description |
|
|
150
|
-
|------|-------------|
|
|
151
|
-
| `get_agent_instructions` | Returns onboarding instructions and live context for AI agents. **Call first.** |
|
|
152
|
-
|
|
153
|
-
### Tasks
|
|
154
|
-
| Tool | Description |
|
|
155
|
-
|------|-------------|
|
|
156
|
-
| `create_task` | Create a task with dependencies, tags, links, and time estimates |
|
|
157
|
-
| `list_tasks` | List tasks with filters (status, project, priority, tag) |
|
|
158
|
-
| `get_task` | Get a task by ID with time tracking info |
|
|
159
|
-
| `update_task` | Update task fields |
|
|
160
|
-
| `update_task_status` | Change status with transition validation |
|
|
161
|
-
| `delete_task` | Delete a task by ID |
|
|
162
|
-
| `bulk_create_tasks` | Create multiple tasks in a single transaction |
|
|
163
|
-
| `search_tasks` | Full-text search by title or description |
|
|
164
|
-
|
|
165
|
-
### Projects
|
|
166
|
-
| Tool | Description |
|
|
167
|
-
|------|-------------|
|
|
168
|
-
| `create_project` | Create a project (active_project or project_idea) |
|
|
169
|
-
| `list_projects` | List all projects with task counts |
|
|
170
|
-
| `get_project` | Get a project with all its tasks |
|
|
171
|
-
| `update_project` | Update project fields |
|
|
172
|
-
| `delete_project` | Delete a project (tasks are unlinked, not deleted) |
|
|
173
|
-
|
|
174
|
-
### Timer
|
|
175
|
-
| Tool | Description |
|
|
176
|
-
|------|-------------|
|
|
177
|
-
| `start_timer` | Start a timer session (task transitions to in_progress) |
|
|
178
|
-
| `pause_timer` | Pause the active session (task transitions to paused) |
|
|
179
|
-
| `stop_timer` | Stop timer with final status (done/partial_done/blocked) |
|
|
180
|
-
| `list_sessions` | List sessions with optional date range filter |
|
|
181
|
-
|
|
182
|
-
### Analytics
|
|
183
|
-
| Tool | Description |
|
|
184
|
-
|------|-------------|
|
|
185
|
-
| `get_analytics` | Summary: focused time, completion rates, status distribution, time per project |
|
|
186
|
-
| `get_timeline` | Focused time grouped by day or week |
|
|
187
|
-
|
|
188
|
-
### Activity
|
|
189
|
-
| Tool | Description |
|
|
190
|
-
|------|-------------|
|
|
191
|
-
| `get_activity_log` | Recent activity: completions, timer events, status changes |
|
|
192
|
-
| `clear_activity_log` | Delete all activity log entries |
|
|
193
|
-
|
|
194
|
-
### Notifications
|
|
195
|
-
| Tool | Description |
|
|
196
|
-
|------|-------------|
|
|
197
|
-
| `list_notifications` | List notifications (filter by unread) |
|
|
198
|
-
| `mark_notification_read` | Mark a single notification as read |
|
|
199
|
-
| `mark_all_notifications_read` | Mark all as read |
|
|
200
|
-
| `clear_notifications` | Delete all notifications |
|
|
201
|
-
|
|
202
|
-
### Agent Inbox
|
|
203
|
-
| Tool | Description |
|
|
204
|
-
|------|-------------|
|
|
205
|
-
| `ask_user` | Post a question to the Agent Inbox for remote response. Returns immediately with message ID |
|
|
206
|
-
| `check_response` | Check if the user has responded to a previously posted question |
|
|
207
|
-
|
|
208
|
-
### Settings
|
|
209
|
-
| Tool | Description |
|
|
210
|
-
|------|-------------|
|
|
211
|
-
| `get_setting` | Get a setting by key (returns default if not set) |
|
|
212
|
-
| `update_setting` | Update or create a setting |
|
|
213
|
-
|
|
214
|
-
## Configuration
|
|
215
|
-
|
|
216
|
-
| Setting | Default | Description |
|
|
217
|
-
|---------|---------|-------------|
|
|
218
|
-
| `TASKFLOW_SSE_PORT` | `3456` | Port for the SSE broadcast server |
|
|
219
|
-
| Database location | `~/.taskflow/taskflow.db` | SQLite database with WAL mode |
|
|
220
|
-
|
|
221
|
-
The SSE server at `http://localhost:3456/events` broadcasts real-time changes to connected UI clients. The `/sync` endpoint returns a full data dump for initial sync.
|
|
162
|
+
It exercises: `whoami`, `create_task`, `list_tasks`, `list_channels`,
|
|
163
|
+
`send_message`, `check_messages`, `register_session`, `heartbeat`,
|
|
164
|
+
`capture_terminal`, `close_session`, `log_activity`, `get_activity` — printing
|
|
165
|
+
`PASS`/`FAIL` per call, and exits non-zero only if a call actually failed against a
|
|
166
|
+
reachable backend.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download a message attachment to disk.
|
|
3
|
+
*
|
|
4
|
+
* The tool exists as insulation, not as an authorization point. `/media` is
|
|
5
|
+
* currently served without auth; when storage is gated at the framework level,
|
|
6
|
+
* only this module changes — it starts sending the agent key as a header — and
|
|
7
|
+
* the agent-facing contract (url in, path out) stays put.
|
|
8
|
+
*
|
|
9
|
+
* Two untrusted inputs: the `url` (must resolve under `/media/`, or this
|
|
10
|
+
* becomes an arbitrary-URL fetcher with a disk write attached) and the
|
|
11
|
+
* filename (must not escape the download directory).
|
|
12
|
+
*/
|
|
13
|
+
export declare class AttachmentDownloadError extends Error {
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
|
16
|
+
export interface DownloadResult {
|
|
17
|
+
path: string;
|
|
18
|
+
name: string;
|
|
19
|
+
size_bytes: number;
|
|
20
|
+
content_type: string;
|
|
21
|
+
}
|
|
22
|
+
export type BinaryFetch = (url: string, init?: {
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
}) => Promise<{
|
|
25
|
+
ok: boolean;
|
|
26
|
+
status: number;
|
|
27
|
+
statusText: string;
|
|
28
|
+
headers: {
|
|
29
|
+
get(name: string): string | null;
|
|
30
|
+
};
|
|
31
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* Validate an attachment url and reduce it to its `/media/...` path.
|
|
35
|
+
*
|
|
36
|
+
* Absolute urls are accepted only for their path — the host is discarded
|
|
37
|
+
* before the fetch (it always targets the configured server, never the url's
|
|
38
|
+
* own host), so a url pointing at another origin can never redirect the
|
|
39
|
+
* fetch there. `server` is free-form user config (any origin, not just
|
|
40
|
+
* loopback), so instead of a hardcoded allowlist, an absolute url's origin is
|
|
41
|
+
* compared against the *configured* server's origin: a match is accepted,
|
|
42
|
+
* anything else is a sign of a confused or hostile caller and is refused
|
|
43
|
+
* outright. When no `server` is given (e.g. validating a url in isolation),
|
|
44
|
+
* there is nothing to compare against, so the host is simply discarded and
|
|
45
|
+
* only the path is used — safe, since it is never fetched.
|
|
46
|
+
*
|
|
47
|
+
* The path is percent-decoded before normalisation so a bare `/media/...`
|
|
48
|
+
* input and an absolute-url input agree on what `%2e%2e`-style traversal
|
|
49
|
+
* resolves to. Anything that does not land under `/media/` after
|
|
50
|
+
* normalisation is rejected.
|
|
51
|
+
*
|
|
52
|
+
* The returned path is DECODED — it is the real storage key, and it is what
|
|
53
|
+
* the on-disk filename is taken from. It must be re-encoded before it goes
|
|
54
|
+
* into a request line; `downloadAttachment` does that per segment.
|
|
55
|
+
*/
|
|
56
|
+
export declare function mediaPathFrom(url: string, server?: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* The filename to write, always a bare basename.
|
|
59
|
+
*
|
|
60
|
+
* Storage keys are already UUID-prefixed, so the url basename is unique by
|
|
61
|
+
* construction and needs no extra prefix. An override is accepted for a
|
|
62
|
+
* friendlier name, but is reduced to its basename first.
|
|
63
|
+
*/
|
|
64
|
+
export declare function downloadFilenameFrom(mediaPath: string, override?: string): string;
|
|
65
|
+
/** Absolute path of the download directory for a project root. */
|
|
66
|
+
export declare function downloadDirFor(root: string): string;
|
|
67
|
+
export declare function downloadAttachment(opts: {
|
|
68
|
+
url: string;
|
|
69
|
+
server: string;
|
|
70
|
+
root: string;
|
|
71
|
+
name?: string;
|
|
72
|
+
key?: string;
|
|
73
|
+
fetchImpl?: BinaryFetch;
|
|
74
|
+
}): Promise<DownloadResult>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download a message attachment to disk.
|
|
3
|
+
*
|
|
4
|
+
* The tool exists as insulation, not as an authorization point. `/media` is
|
|
5
|
+
* currently served without auth; when storage is gated at the framework level,
|
|
6
|
+
* only this module changes — it starts sending the agent key as a header — and
|
|
7
|
+
* the agent-facing contract (url in, path out) stays put.
|
|
8
|
+
*
|
|
9
|
+
* Two untrusted inputs: the `url` (must resolve under `/media/`, or this
|
|
10
|
+
* becomes an arbitrary-URL fetcher with a disk write attached) and the
|
|
11
|
+
* filename (must not escape the download directory).
|
|
12
|
+
*/
|
|
13
|
+
import { access, mkdir, writeFile } from "node:fs/promises";
|
|
14
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
export class AttachmentDownloadError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "AttachmentDownloadError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const MEDIA_PREFIX = "/media/";
|
|
22
|
+
/** Cap on decode passes — enough for any real url, bounded for a hostile one. */
|
|
23
|
+
const MAX_DECODE_PASSES = 5;
|
|
24
|
+
/**
|
|
25
|
+
* Percent-decode until the string stops changing (bounded).
|
|
26
|
+
*
|
|
27
|
+
* One pass is not enough: `%252e%252e` decodes to the literal `%2e%2e`, which
|
|
28
|
+
* would sail past the traversal check and rely on the upstream decoder to
|
|
29
|
+
* catch it. Looping removes that reliance.
|
|
30
|
+
*
|
|
31
|
+
* A decode failure is NOT fatal. The backend emits urls unencoded, so a lone
|
|
32
|
+
* `%` is a legal character in a real storage key (`50%.png`) and
|
|
33
|
+
* `decodeURIComponent` throws on it. Falling back to the undecoded string
|
|
34
|
+
* treats it as the literal it is.
|
|
35
|
+
*/
|
|
36
|
+
function fullyDecode(path) {
|
|
37
|
+
let current = path;
|
|
38
|
+
for (let i = 0; i < MAX_DECODE_PASSES; i++) {
|
|
39
|
+
let next;
|
|
40
|
+
try {
|
|
41
|
+
next = decodeURIComponent(current);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return current;
|
|
45
|
+
}
|
|
46
|
+
if (next === current)
|
|
47
|
+
return current;
|
|
48
|
+
current = next;
|
|
49
|
+
}
|
|
50
|
+
return current;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Encode a decoded media path for use in a request line, segment by segment.
|
|
54
|
+
*
|
|
55
|
+
* The `/` separators stay literal; everything else is percent-encoded. Without
|
|
56
|
+
* this, a key containing `#` or `?` truncates the request at the fragment or
|
|
57
|
+
* query and 404s on a file that exists.
|
|
58
|
+
*/
|
|
59
|
+
function encodeMediaPath(mediaPath) {
|
|
60
|
+
return mediaPath.split("/").map(encodeURIComponent).join("/");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Validate an attachment url and reduce it to its `/media/...` path.
|
|
64
|
+
*
|
|
65
|
+
* Absolute urls are accepted only for their path — the host is discarded
|
|
66
|
+
* before the fetch (it always targets the configured server, never the url's
|
|
67
|
+
* own host), so a url pointing at another origin can never redirect the
|
|
68
|
+
* fetch there. `server` is free-form user config (any origin, not just
|
|
69
|
+
* loopback), so instead of a hardcoded allowlist, an absolute url's origin is
|
|
70
|
+
* compared against the *configured* server's origin: a match is accepted,
|
|
71
|
+
* anything else is a sign of a confused or hostile caller and is refused
|
|
72
|
+
* outright. When no `server` is given (e.g. validating a url in isolation),
|
|
73
|
+
* there is nothing to compare against, so the host is simply discarded and
|
|
74
|
+
* only the path is used — safe, since it is never fetched.
|
|
75
|
+
*
|
|
76
|
+
* The path is percent-decoded before normalisation so a bare `/media/...`
|
|
77
|
+
* input and an absolute-url input agree on what `%2e%2e`-style traversal
|
|
78
|
+
* resolves to. Anything that does not land under `/media/` after
|
|
79
|
+
* normalisation is rejected.
|
|
80
|
+
*
|
|
81
|
+
* The returned path is DECODED — it is the real storage key, and it is what
|
|
82
|
+
* the on-disk filename is taken from. It must be re-encoded before it goes
|
|
83
|
+
* into a request line; `downloadAttachment` does that per segment.
|
|
84
|
+
*/
|
|
85
|
+
export function mediaPathFrom(url, server) {
|
|
86
|
+
let path;
|
|
87
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) {
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = new URL(url);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new AttachmentDownloadError(`Not a valid attachment url: ${url}`);
|
|
94
|
+
}
|
|
95
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
96
|
+
throw new AttachmentDownloadError(`Attachment url must be http(s) or a /media path, got: ${url}`);
|
|
97
|
+
}
|
|
98
|
+
// The host is discarded below regardless -- the actual fetch always goes
|
|
99
|
+
// to the configured server, never to whatever host this url names. But an
|
|
100
|
+
// absolute url naming some OTHER host than the configured server is still
|
|
101
|
+
// a sign of a confused or hostile caller, so it is rejected outright
|
|
102
|
+
// rather than silently tolerated.
|
|
103
|
+
if (server) {
|
|
104
|
+
let serverOrigin;
|
|
105
|
+
try {
|
|
106
|
+
serverOrigin = new URL(server).origin;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new AttachmentDownloadError(`Not a valid server origin: ${server}`);
|
|
110
|
+
}
|
|
111
|
+
if (parsed.origin !== serverOrigin) {
|
|
112
|
+
throw new AttachmentDownloadError(`Attachment url points at another host, got: ${url}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
path = parsed.pathname;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
path = url;
|
|
119
|
+
}
|
|
120
|
+
// Percent-decode before normalising, so `/media/%2e%2e/etc/passwd` collapses
|
|
121
|
+
// the same way whether it arrived as a bare path or inside an absolute url.
|
|
122
|
+
const decodedPath = fullyDecode(path);
|
|
123
|
+
// Normalise so `/media/../etc/passwd` cannot masquerade as a media path.
|
|
124
|
+
const normalised = resolve("/", decodedPath);
|
|
125
|
+
if (!normalised.startsWith(MEDIA_PREFIX)) {
|
|
126
|
+
throw new AttachmentDownloadError(`Attachment url must be under ${MEDIA_PREFIX} — got: ${url}`);
|
|
127
|
+
}
|
|
128
|
+
return normalised;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The filename to write, always a bare basename.
|
|
132
|
+
*
|
|
133
|
+
* Storage keys are already UUID-prefixed, so the url basename is unique by
|
|
134
|
+
* construction and needs no extra prefix. An override is accepted for a
|
|
135
|
+
* friendlier name, but is reduced to its basename first.
|
|
136
|
+
*/
|
|
137
|
+
export function downloadFilenameFrom(mediaPath, override) {
|
|
138
|
+
const raw = override && override.trim() ? override : mediaPath;
|
|
139
|
+
const name = basename(raw.replace(/[/\\]+$/, ""));
|
|
140
|
+
if (!name || name === "." || name === "..")
|
|
141
|
+
return "attachment";
|
|
142
|
+
return name;
|
|
143
|
+
}
|
|
144
|
+
/** Absolute path of the download directory for a project root. */
|
|
145
|
+
export function downloadDirFor(root) {
|
|
146
|
+
return join(root, ".taskflow", "attachments");
|
|
147
|
+
}
|
|
148
|
+
export async function downloadAttachment(opts) {
|
|
149
|
+
const mediaPath = mediaPathFrom(opts.url, opts.server);
|
|
150
|
+
const filename = downloadFilenameFrom(mediaPath, opts.name);
|
|
151
|
+
const dir = downloadDirFor(opts.root);
|
|
152
|
+
const target = resolve(dir, filename);
|
|
153
|
+
// Belt and braces: the basename reduction above should make escape
|
|
154
|
+
// impossible, so this asserts the invariant rather than trusting it.
|
|
155
|
+
const rel = relative(dir, target);
|
|
156
|
+
if (rel === "" || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
|
|
157
|
+
throw new AttachmentDownloadError(`Refusing to write outside the download directory: ${filename}`);
|
|
158
|
+
}
|
|
159
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
160
|
+
// `mediaPath` is decoded (see `mediaPathFrom`); the backend emits urls
|
|
161
|
+
// unencoded, so it can legitimately contain `#`, `?`, `%`, spaces and
|
|
162
|
+
// unicode. Re-encode per segment before it becomes a request line.
|
|
163
|
+
const absolute = `${opts.server.replace(/\/$/, "")}${encodeMediaPath(mediaPath)}`;
|
|
164
|
+
// When storage is gated at the framework level, the auth header goes HERE and
|
|
165
|
+
// nothing else in the system needs to change.
|
|
166
|
+
const headers = {};
|
|
167
|
+
if (opts.key)
|
|
168
|
+
headers.Authorization = `Agent ${opts.key}`;
|
|
169
|
+
const res = await doFetch(absolute, { headers });
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
throw new AttachmentDownloadError(`Download failed: ${res.status} ${res.statusText} for ${mediaPath}`);
|
|
172
|
+
}
|
|
173
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
174
|
+
await mkdir(dir, { recursive: true });
|
|
175
|
+
// Downloads are scratch, never committed. Written only when absent — if
|
|
176
|
+
// anything else ever keeps committed state under `.taskflow/`, an
|
|
177
|
+
// unconditional write here would silently ignore it.
|
|
178
|
+
const gitignore = join(opts.root, ".taskflow", ".gitignore");
|
|
179
|
+
try {
|
|
180
|
+
await access(gitignore);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
await writeFile(gitignore, "*\n");
|
|
184
|
+
}
|
|
185
|
+
await writeFile(target, bytes);
|
|
186
|
+
return {
|
|
187
|
+
path: target,
|
|
188
|
+
name: filename,
|
|
189
|
+
size_bytes: bytes.length,
|
|
190
|
+
content_type: res.headers.get("content-type") || "application/octet-stream",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=attachment-download.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attachment-download.js","sourceRoot":"","sources":["../src/attachment-download.ts"],"names":[],"mappings":"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"}
|