@dalmasonto/taskflow-mcp 1.0.35 → 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 +121 -218
- 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 -28
- package/dist/agent-registry.js +0 -158
- 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/retry.d.ts +0 -34
- package/dist/retry.js +0 -94
- package/dist/sse.d.ts +0 -10
- package/dist/sse.js +0 -824
- package/dist/tmux-bridge.d.ts +0 -13
- package/dist/tmux-bridge.js +0 -217
- 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 -272
- 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 -83
- package/dist/types.js +0 -30
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize a tool payload for the `metadata_json` column.
|
|
3
|
+
*
|
|
4
|
+
* TWO RULES, learned the hard way:
|
|
5
|
+
*
|
|
6
|
+
* 1. NEVER slice the JSON string. A string cut mid-token does not parse, so the
|
|
7
|
+
* whole record becomes unreadable — not merely shortened. That is what left
|
|
8
|
+
* recorded AskUserQuestion rows ending `…"ans` with their options
|
|
9
|
+
* unrecoverable, despite most of the payload being present.
|
|
10
|
+
*
|
|
11
|
+
* 2. Do not budget the payload as a whole. A tool call is a structure, and
|
|
12
|
+
* clipping it globally means the fields that identify the call (file_path,
|
|
13
|
+
* description, flags) can be lost to make room for a body nobody needed in
|
|
14
|
+
* full. Instead, the ONE field that carries bulk is capped per tool — a
|
|
15
|
+
* Write's `content`, a Bash `command` — and everything else is recorded
|
|
16
|
+
* whole, however long the payload ends up.
|
|
17
|
+
*
|
|
18
|
+
* Kept dependency-free so the hook needs no build step.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The bulk-carrying field(s) of each tool we know the shape of.
|
|
23
|
+
*
|
|
24
|
+
* Only these are ever shortened. A tool that is not listed is recorded verbatim:
|
|
25
|
+
* guessing which of its fields is "the big one" would be how the identifying
|
|
26
|
+
* fields start disappearing again.
|
|
27
|
+
*/
|
|
28
|
+
const BULK_FIELDS = {
|
|
29
|
+
Bash: ["command"],
|
|
30
|
+
Write: ["content"],
|
|
31
|
+
Edit: ["old_string", "new_string"],
|
|
32
|
+
NotebookEdit: ["new_source"],
|
|
33
|
+
Task: ["prompt"],
|
|
34
|
+
Agent: ["prompt"],
|
|
35
|
+
Workflow: ["script"],
|
|
36
|
+
Artifact: ["content"],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How much of a bulk field to keep. Generous on purpose — the point is to stop a
|
|
41
|
+
* megabyte of file content landing in an activity row, not to make the record
|
|
42
|
+
* terse. The content is already in the file; the command is not, so this is the
|
|
43
|
+
* only copy of it, which argues for keeping plenty.
|
|
44
|
+
*/
|
|
45
|
+
export const BULK_FIELD_MAX_CHARS = 20_000;
|
|
46
|
+
|
|
47
|
+
/** Shorten one string, saying exactly how much went missing. */
|
|
48
|
+
function capString(text, cap) {
|
|
49
|
+
if (typeof text !== "string" || text.length <= cap) return text;
|
|
50
|
+
return `${text.slice(0, cap)}…[+${text.length - cap} chars]`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cap the named fields wherever they appear in the payload, at any depth.
|
|
55
|
+
*
|
|
56
|
+
* Depth matters: MultiEdit nests its strings inside an `edits` array, and a
|
|
57
|
+
* top-level-only pass would miss them entirely.
|
|
58
|
+
*/
|
|
59
|
+
function capFields(value, fields, cap, seen = new WeakSet()) {
|
|
60
|
+
if (value && typeof value === "object") {
|
|
61
|
+
// A cycle would recurse until the stack blows — and a crashed hook is a
|
|
62
|
+
// crashed tool call. Mark it and move on instead.
|
|
63
|
+
if (seen.has(value)) return "[circular]";
|
|
64
|
+
seen.add(value);
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(value)) return value.map((item) => capFields(item, fields, cap, seen));
|
|
67
|
+
if (value && typeof value === "object") {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
70
|
+
out[key] =
|
|
71
|
+
fields.includes(key) && typeof inner === "string"
|
|
72
|
+
? capString(inner, cap)
|
|
73
|
+
: capFields(inner, fields, cap, seen);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* JSON for `metadata_json`. Always parses; never globally truncated.
|
|
82
|
+
*
|
|
83
|
+
* `toolName` selects the bulk-field policy. Omit it (session events,
|
|
84
|
+
* notifications) and the value is recorded exactly as given.
|
|
85
|
+
*/
|
|
86
|
+
export function compactMetadata(value, toolName) {
|
|
87
|
+
if (value == null) return undefined;
|
|
88
|
+
|
|
89
|
+
const fields = toolName ? BULK_FIELDS[toolName] : undefined;
|
|
90
|
+
const shaped = fields ? capFields(value, fields, BULK_FIELD_MAX_CHARS) : value;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
return JSON.stringify(shaped);
|
|
94
|
+
} catch {
|
|
95
|
+
// Circular or otherwise unserializable: record nothing rather than
|
|
96
|
+
// something that will not parse.
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a Claude Code permission prompt off the agent's terminal (#48).
|
|
3
|
+
*
|
|
4
|
+
* `AskUserQuestion` arrives as a hook payload with its options attached, so it
|
|
5
|
+
* reaches the dashboard intact. A *tool approval* does not: the Notification
|
|
6
|
+
* hook fires with nothing but a message string — the shapes actually observed in
|
|
7
|
+
* `taskflow_task_activity` are exactly "Claude needs your permission" and
|
|
8
|
+
* "Claude is waiting for your input", with no tool name and no options. So the
|
|
9
|
+
* options have to be read off the screen.
|
|
10
|
+
*
|
|
11
|
+
* SAFETY. The numbers here are typed into a live terminal. The option list is
|
|
12
|
+
* NOT fixed — a simple prompt offers two, a Bash prompt offers three:
|
|
13
|
+
*
|
|
14
|
+
* 1. Yes
|
|
15
|
+
* 2. Yes, and don't ask again for: gh issue *
|
|
16
|
+
* 3. No
|
|
17
|
+
*
|
|
18
|
+
* Assuming a generic yes/no and sending "2" for No would pick "don't ask again"
|
|
19
|
+
* on a command the human just denied. Everything here therefore REFUSES on
|
|
20
|
+
* anything it does not fully recognise: a null result makes the caller surface a
|
|
21
|
+
* read-only notice instead, and no keystroke is ever sent. An agent left waiting
|
|
22
|
+
* is recoverable; a wrongly-approved command is not.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately dependency-free (like metadata.mjs) so the hook needs no build.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The anchor line Claude Code renders above the choices. */
|
|
28
|
+
const PROCEED = "Do you want to proceed?";
|
|
29
|
+
|
|
30
|
+
/** `1. Yes`, optionally preceded by the selection caret (`>` or `❯`). */
|
|
31
|
+
const OPTION_LINE = /^\s*[>❯]?\s*(\d+)\.\s+(.*\S)\s*$/;
|
|
32
|
+
|
|
33
|
+
/** How much of the screen above the anchor to keep as context. */
|
|
34
|
+
const MAX_CONTEXT_LINES = 14;
|
|
35
|
+
const MAX_QUESTION_CHARS = 2000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a Notification message is the "blocked on the user" kind rather than
|
|
39
|
+
* the idle "waiting for your input" nudge.
|
|
40
|
+
*
|
|
41
|
+
* NOT sufficient on its own. Claude Code fires "Claude needs your permission"
|
|
42
|
+
* for AskUserQuestion as well as for tool approvals — the message does not
|
|
43
|
+
* distinguish them, and trusting it destroyed live questions on 2026-07-24 (see
|
|
44
|
+
* the regression test). `parsePermissionPrompt` is what actually tells them
|
|
45
|
+
* apart: only a real approval screen carries the proceed anchor. Always gate on
|
|
46
|
+
* a successful parse, never on this alone.
|
|
47
|
+
*
|
|
48
|
+
* @param {string | undefined | null} message
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
export function isPermissionNotification(message) {
|
|
52
|
+
if (!message) return false;
|
|
53
|
+
return /needs your permission/i.test(message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The permission prompt currently on screen, or null if the screen is not one we
|
|
58
|
+
* recognise with certainty.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} pane raw `tmux capture-pane -p` output
|
|
61
|
+
* @returns {{ question: string, options: { number: number, label: string }[] } | null}
|
|
62
|
+
*/
|
|
63
|
+
export function parsePermissionPrompt(pane) {
|
|
64
|
+
if (!pane) return null;
|
|
65
|
+
const lines = pane.split("\n");
|
|
66
|
+
|
|
67
|
+
// The LAST anchor: scrollback can hold earlier, already-resolved prompts, and
|
|
68
|
+
// their numbering may differ from the live one.
|
|
69
|
+
const anchor = lines.map((line) => line.includes(PROCEED)).lastIndexOf(true);
|
|
70
|
+
if (anchor === -1) return null;
|
|
71
|
+
|
|
72
|
+
const options = [];
|
|
73
|
+
for (let i = anchor + 1; i < lines.length; i++) {
|
|
74
|
+
const line = lines[i];
|
|
75
|
+
if (!line.trim()) {
|
|
76
|
+
// Blank lines before the list are padding; after it, the list is over.
|
|
77
|
+
if (options.length) break;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const match = OPTION_LINE.exec(line);
|
|
81
|
+
// Any non-option line ends the list — this is what keeps the footer
|
|
82
|
+
// ("Esc to cancel · Tab to amend") out of the options.
|
|
83
|
+
if (!match) break;
|
|
84
|
+
options.push({ number: Number(match[1]), label: match[2] });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Contiguous and 1-based, or we do not understand the screen. A gap means we
|
|
88
|
+
// misread a line, and a misread list is one that types the wrong digit.
|
|
89
|
+
if (options.length < 2) return null;
|
|
90
|
+
if (options.some((option, index) => option.number !== index + 1)) return null;
|
|
91
|
+
|
|
92
|
+
const context = lines
|
|
93
|
+
.slice(Math.max(0, anchor - MAX_CONTEXT_LINES), anchor)
|
|
94
|
+
.map((line) => line.trimEnd())
|
|
95
|
+
.join("\n")
|
|
96
|
+
.trim();
|
|
97
|
+
|
|
98
|
+
const question = (context ? `${context}\n\n${PROCEED}` : PROCEED).slice(-MAX_QUESTION_CHARS);
|
|
99
|
+
return { question, options };
|
|
100
|
+
}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* TaskFlow Claude Code hook — a standalone Node script (no build step).
|
|
4
|
+
*
|
|
5
|
+
* Claude Code invokes it for lifecycle events, passing the event JSON on stdin.
|
|
6
|
+
* The hook resolves `.taskflow.json` + a profile, maps the event to a TaskFlow
|
|
7
|
+
* activity (and, for session lifecycle, a session register/heartbeat/close), and
|
|
8
|
+
* POSTs it with the profile key.
|
|
9
|
+
*
|
|
10
|
+
* Contract: it MUST be fast and MUST NEVER block or crash the agent. Every
|
|
11
|
+
* failure path swallows the error and exits 0; requests have a short timeout.
|
|
12
|
+
*
|
|
13
|
+
* Wire it in `.claude/settings.json` (see `.claude/settings.example.json`):
|
|
14
|
+
* SessionStart / PreToolUse / PostToolUse / Stop / Notification →
|
|
15
|
+
* node <abs path>/hooks/taskflow-hook.mjs
|
|
16
|
+
*
|
|
17
|
+
* Profile: reads `TASKFLOW_PROFILE` (else default_profile, else "main").
|
|
18
|
+
* Config: reads `TASKFLOW_CONFIG`, else walks up from `cwd` to `.taskflow.json`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
// Structure-preserving serializer: a JSON string sliced mid-token does not parse,
|
|
25
|
+
// so oversized payloads are shortened field-by-field instead. See metadata.mjs.
|
|
26
|
+
import { compactMetadata } from "./metadata.mjs";
|
|
27
|
+
// #48: a tool-approval request arrives as a bare Notification with no options,
|
|
28
|
+
// so they are read off the pane. Refuses rather than guesses — see the module.
|
|
29
|
+
import { isPermissionNotification, parsePermissionPrompt } from "./permission-prompt.mjs";
|
|
30
|
+
// #56: most tool calls are read-only noise that crowds the activity journal.
|
|
31
|
+
import { shouldLogTool } from "./tool-logging.mjs";
|
|
32
|
+
import { hostname, tmpdir } from "node:os";
|
|
33
|
+
|
|
34
|
+
const REQUEST_TIMEOUT_MS = 2500;
|
|
35
|
+
|
|
36
|
+
/** Never let this process take the agent down: log to stderr and exit 0. */
|
|
37
|
+
function bail(reason) {
|
|
38
|
+
if (process.env.TASKFLOW_HOOK_DEBUG) {
|
|
39
|
+
process.stderr.write(`taskflow-hook: ${reason}\n`);
|
|
40
|
+
}
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- config resolution (inlined so the hook needs no build/deps) ----
|
|
45
|
+
|
|
46
|
+
function findConfigPath(startDir) {
|
|
47
|
+
const explicit = process.env.TASKFLOW_CONFIG;
|
|
48
|
+
if (explicit) {
|
|
49
|
+
const abs = resolve(explicit);
|
|
50
|
+
return existsSync(abs) ? abs : null;
|
|
51
|
+
}
|
|
52
|
+
let dir = resolve(startDir || process.cwd());
|
|
53
|
+
while (true) {
|
|
54
|
+
const candidate = join(dir, ".taskflow.json");
|
|
55
|
+
if (existsSync(candidate)) return candidate;
|
|
56
|
+
const parent = dirname(dir);
|
|
57
|
+
if (parent === dir) return null;
|
|
58
|
+
dir = parent;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadProfile(startDir) {
|
|
63
|
+
const path = findConfigPath(startDir);
|
|
64
|
+
if (!path) return null;
|
|
65
|
+
let config;
|
|
66
|
+
try {
|
|
67
|
+
config = JSON.parse(readFileSync(path, "utf8"));
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const profiles = config.profiles || {};
|
|
72
|
+
const name =
|
|
73
|
+
(process.env.TASKFLOW_PROFILE && process.env.TASKFLOW_PROFILE.trim()) ||
|
|
74
|
+
config.default_profile ||
|
|
75
|
+
"main";
|
|
76
|
+
const profile = profiles[name];
|
|
77
|
+
if (!profile || !profile.key) return null;
|
|
78
|
+
const server = String(config.server || "").replace(/\/+$/, "");
|
|
79
|
+
if (!server) return null;
|
|
80
|
+
return { server, key: profile.key, profileName: name };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- tiny HTTP helper (best-effort, short timeout) ----
|
|
84
|
+
|
|
85
|
+
async function post(profile, path, body) {
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`${profile.server}${path}`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
Authorization: `Agent ${profile.key}`,
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
signal: controller.signal,
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) return null;
|
|
99
|
+
const text = await res.text();
|
|
100
|
+
try {
|
|
101
|
+
return text ? JSON.parse(text) : {};
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- session id cache, keyed by the Claude session id ----
|
|
113
|
+
|
|
114
|
+
function stateFile(sessionKey) {
|
|
115
|
+
const safe = String(sessionKey || "default").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
116
|
+
return join(tmpdir(), `taskflow-hook-${safe}.json`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readSessionId(sessionKey) {
|
|
120
|
+
try {
|
|
121
|
+
const raw = readFileSync(stateFile(sessionKey), "utf8");
|
|
122
|
+
const parsed = JSON.parse(raw);
|
|
123
|
+
return typeof parsed.sessionId === "number" ? parsed.sessionId : null;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeSessionId(sessionKey, sessionId) {
|
|
130
|
+
try {
|
|
131
|
+
writeFileSync(stateFile(sessionKey), JSON.stringify({ sessionId }));
|
|
132
|
+
} catch {
|
|
133
|
+
/* ignore */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// #48: whether this session has an outstanding permission prompt on the
|
|
138
|
+
/// dashboard. Kept in the same state file so the common path — every PostToolUse
|
|
139
|
+
/// of every tool — costs one local read rather than an HTTP round trip. The hook
|
|
140
|
+
/// must never add latency to a tool call.
|
|
141
|
+
function readPermissionPending(sessionKey) {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(readFileSync(stateFile(sessionKey), "utf8")).permissionPending === true;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function setPermissionPending(sessionKey, pending) {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(readFileSync(stateFile(sessionKey), "utf8"));
|
|
152
|
+
parsed.permissionPending = pending;
|
|
153
|
+
writeFileSync(stateFile(sessionKey), JSON.stringify(parsed));
|
|
154
|
+
} catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- stdin ----
|
|
160
|
+
|
|
161
|
+
function readStdin() {
|
|
162
|
+
return new Promise((resolvePromise) => {
|
|
163
|
+
let data = "";
|
|
164
|
+
let settled = false;
|
|
165
|
+
const done = () => {
|
|
166
|
+
if (!settled) {
|
|
167
|
+
settled = true;
|
|
168
|
+
resolvePromise(data);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
// Guard against a hook invoked with no piped stdin.
|
|
172
|
+
const guard = setTimeout(done, 1000);
|
|
173
|
+
process.stdin.setEncoding("utf8");
|
|
174
|
+
process.stdin.on("data", (chunk) => {
|
|
175
|
+
data += chunk;
|
|
176
|
+
});
|
|
177
|
+
process.stdin.on("end", () => {
|
|
178
|
+
clearTimeout(guard);
|
|
179
|
+
done();
|
|
180
|
+
});
|
|
181
|
+
process.stdin.on("error", () => {
|
|
182
|
+
clearTimeout(guard);
|
|
183
|
+
done();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The tmux pane this Claude session runs in, or null.
|
|
193
|
+
*
|
|
194
|
+
* Mirrors detectTmuxPane() in src/tmux.ts: prefer $TMUX_PANE, else match an
|
|
195
|
+
* ancestor's controlling tty against tmux's pane list. Kept inline because this
|
|
196
|
+
* hook is deliberately dependency-free — it must run before anything is built.
|
|
197
|
+
*/
|
|
198
|
+
function detectTmuxPane() {
|
|
199
|
+
const fromEnv = (process.env.TMUX_PANE || "").trim();
|
|
200
|
+
if (fromEnv) return fromEnv;
|
|
201
|
+
try {
|
|
202
|
+
const panes = execFileSync("tmux", ["list-panes", "-a", "-F", "#{pane_id} #{pane_tty}"], {
|
|
203
|
+
encoding: "utf8",
|
|
204
|
+
timeout: 3000,
|
|
205
|
+
})
|
|
206
|
+
.trim()
|
|
207
|
+
.split("\n")
|
|
208
|
+
.map((line) => line.split(" "))
|
|
209
|
+
.filter((parts) => parts.length === 2);
|
|
210
|
+
let pid = process.ppid;
|
|
211
|
+
for (let depth = 0; depth < 4 && pid > 1; depth += 1) {
|
|
212
|
+
try {
|
|
213
|
+
const tty = execFileSync("readlink", [`/proc/${pid}/fd/0`], {
|
|
214
|
+
encoding: "utf8",
|
|
215
|
+
timeout: 3000,
|
|
216
|
+
}).trim();
|
|
217
|
+
const hit = panes.find(([, paneTty]) => paneTty === tty);
|
|
218
|
+
if (hit) return hit[0];
|
|
219
|
+
} catch {
|
|
220
|
+
/* try the next ancestor */
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
pid = Number(
|
|
224
|
+
execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
|
|
225
|
+
encoding: "utf8",
|
|
226
|
+
timeout: 3000,
|
|
227
|
+
}).trim(),
|
|
228
|
+
);
|
|
229
|
+
} catch {
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
/* no tmux */
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Report a pending AskUserQuestion so a human can answer it from the dashboard.
|
|
242
|
+
*
|
|
243
|
+
* Only the FIRST question is reported: the tool accepts several, but the agent's
|
|
244
|
+
* terminal presents them one at a time, and answering a later one out of order
|
|
245
|
+
* would send keys to the wrong screen.
|
|
246
|
+
*
|
|
247
|
+
* The payload goes into dedicated columns rather than metadata, so the options
|
|
248
|
+
* are the record itself and cannot be shortened away by any budget.
|
|
249
|
+
*/
|
|
250
|
+
async function reportPrompt(profile, sessionId, toolInput) {
|
|
251
|
+
const asked = Array.isArray(toolInput?.questions) ? toolInput.questions : [];
|
|
252
|
+
// Every question must carry real options; one malformed entry would shift the
|
|
253
|
+
// answers out of alignment with the questions they belong to.
|
|
254
|
+
const usable = asked.filter(
|
|
255
|
+
(q) => q && Array.isArray(q.options) && q.options.length >= 2,
|
|
256
|
+
);
|
|
257
|
+
if (!usable.length) return;
|
|
258
|
+
|
|
259
|
+
const questions = usable.map((question) => {
|
|
260
|
+
// Numbered to match what the terminal renders: option N is the key to press.
|
|
261
|
+
const options = question.options.map((option, index) => ({
|
|
262
|
+
number: index + 1,
|
|
263
|
+
label: String(option.label ?? "").slice(0, 200),
|
|
264
|
+
description: String(option.description ?? "").slice(0, 500),
|
|
265
|
+
// The preview is often the whole point of the question — a mockup, a diff,
|
|
266
|
+
// a config block. Dropping it left the dashboard asking someone to choose
|
|
267
|
+
// between things they could not see.
|
|
268
|
+
...(option.preview ? { preview: String(option.preview).slice(0, 4000) } : {}),
|
|
269
|
+
}));
|
|
270
|
+
// The terminal appends a free-text "Type something" row at N+1 that the hook
|
|
271
|
+
// never sees (it is added in the harness render layer, absent from
|
|
272
|
+
// toolInput). Synthesize it so the dashboard can offer the same free-text
|
|
273
|
+
// answer — for BOTH single- and multi-select questions (#30): the terminal
|
|
274
|
+
// shows the Other row on single-select too, it was just never mirrored.
|
|
275
|
+
options.push({ number: options.length + 1, label: "Type something", isOther: true });
|
|
276
|
+
return {
|
|
277
|
+
question: String(question.question ?? question.header ?? "Agent is asking").slice(0, 2000),
|
|
278
|
+
kind: question.multiSelect ? "multi" : "single",
|
|
279
|
+
options,
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const first = questions[0];
|
|
284
|
+
await post(profile, `/api/taskflow/agents/sessions/${sessionId}/prompt`, {
|
|
285
|
+
// The row's own columns describe the FIRST question, for list views and for
|
|
286
|
+
// readers written before multi-question support.
|
|
287
|
+
question: first.question,
|
|
288
|
+
options_json: JSON.stringify(questions),
|
|
289
|
+
kind: questions.length > 1 ? "set" : first.kind,
|
|
290
|
+
// Identity of the whole SET, so a re-render or retry updates one row.
|
|
291
|
+
fingerprint: usable
|
|
292
|
+
.map((q) => `${q.header ?? ""}::${q.question ?? ""}`)
|
|
293
|
+
.join("|")
|
|
294
|
+
.slice(0, 300),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Report a tool-approval request as a prompt (#48).
|
|
300
|
+
*
|
|
301
|
+
* The options are read off the pane because the Notification payload has none.
|
|
302
|
+
* When the screen cannot be parsed with certainty the prompt is still reported,
|
|
303
|
+
* but with NO options — the dashboard then shows it read-only, and
|
|
304
|
+
* `keystrokesForPrompt` yields nothing for an option-less row, so no digit can
|
|
305
|
+
* be typed into a screen we did not understand.
|
|
306
|
+
*/
|
|
307
|
+
async function reportPermissionPrompt(profile, sessionId, pane) {
|
|
308
|
+
let parsed = null;
|
|
309
|
+
if (pane) {
|
|
310
|
+
try {
|
|
311
|
+
const screen = execFileSync("tmux", ["capture-pane", "-p", "-t", pane], {
|
|
312
|
+
encoding: "utf8",
|
|
313
|
+
timeout: 3000,
|
|
314
|
+
});
|
|
315
|
+
parsed = parsePermissionPrompt(screen);
|
|
316
|
+
} catch {
|
|
317
|
+
// No tmux, pane gone, or capture failed — fall through to read-only.
|
|
318
|
+
parsed = null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// #48 FOLLOW-UP: report NOTHING unless the screen was understood.
|
|
323
|
+
//
|
|
324
|
+
// Reporting an unreadable approval as an option-less notice looked harmless
|
|
325
|
+
// and was not: `report_session_prompt` cancels whatever else is pending
|
|
326
|
+
// ("a different question replaces the old one", views.rs), so an unanswerable
|
|
327
|
+
// notice DESTROYED a real, answerable AskUserQuestion that the agent was
|
|
328
|
+
// actually blocked on. The notice added nothing — it could not be answered —
|
|
329
|
+
// and cost the one prompt that could.
|
|
330
|
+
//
|
|
331
|
+
// A missed approval leaves the terminal exactly as it was before this feature
|
|
332
|
+
// existed. Clobbering a live question does not. So silence is the safe half.
|
|
333
|
+
if (!parsed) return false;
|
|
334
|
+
|
|
335
|
+
await post(profile, `/api/taskflow/agents/sessions/${sessionId}/prompt`, {
|
|
336
|
+
question: parsed.question,
|
|
337
|
+
// The legacy single-question shape: a bare option list.
|
|
338
|
+
options_json: JSON.stringify(parsed.options.map((o) => ({ number: o.number, label: o.label }))),
|
|
339
|
+
kind: "single",
|
|
340
|
+
// Identity of THIS approval, so a re-render updates one row instead of
|
|
341
|
+
// stacking duplicates. The question text carries the command being approved.
|
|
342
|
+
fingerprint: `permission::${parsed.question}`.slice(0, 300),
|
|
343
|
+
});
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function main() {
|
|
348
|
+
const stdin = await readStdin();
|
|
349
|
+
let event = {};
|
|
350
|
+
try {
|
|
351
|
+
event = stdin ? JSON.parse(stdin) : {};
|
|
352
|
+
} catch {
|
|
353
|
+
// A malformed/absent payload is not worth crashing over.
|
|
354
|
+
bail("unparseable stdin");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const startDir = event.cwd || process.cwd();
|
|
358
|
+
const profile = loadProfile(startDir);
|
|
359
|
+
if (!profile) bail("no usable .taskflow.json / profile");
|
|
360
|
+
|
|
361
|
+
const eventName =
|
|
362
|
+
event.hook_event_name || event.hookEventName || event.event || "unknown";
|
|
363
|
+
const claudeSessionId = event.session_id || event.sessionId || `${hostname()}:${process.pid}`;
|
|
364
|
+
// Prefer the PANE as the session key so the hook, the MCP tools and the
|
|
365
|
+
// terminal mirror all register the SAME session. Keying on Claude's session id
|
|
366
|
+
// instead produced a second row per agent — the dashboard then showed several
|
|
367
|
+
// "connected sessions" for one agent, and the terminal panel could pick the
|
|
368
|
+
// one that never streams.
|
|
369
|
+
const pane = detectTmuxPane();
|
|
370
|
+
const sessionIdentifier = pane ? `tmux:${hostname()}:${pane}` : `claude:${claudeSessionId}`;
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
if (eventName === "SessionStart") {
|
|
374
|
+
const session = await post(profile, "/api/taskflow/agents/sessions", {
|
|
375
|
+
session_identifier: sessionIdentifier,
|
|
376
|
+
host: hostname(),
|
|
377
|
+
pid: process.pid,
|
|
378
|
+
cwd: startDir,
|
|
379
|
+
transport: "claude-code",
|
|
380
|
+
});
|
|
381
|
+
if (session && typeof session.id === "number") {
|
|
382
|
+
writeSessionId(claudeSessionId, session.id);
|
|
383
|
+
await post(profile, `/api/taskflow/agents/sessions/${session.id}/heartbeat`, {
|
|
384
|
+
status: "busy",
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
await post(profile, "/api/taskflow/agents/activity", {
|
|
388
|
+
action: "session_start",
|
|
389
|
+
metadata_json: compactMetadata({ source: event.source, cwd: startDir }),
|
|
390
|
+
});
|
|
391
|
+
} else if (eventName === "PreToolUse" || eventName === "PostToolUse") {
|
|
392
|
+
const toolName = event.tool_name || event.toolName || "tool";
|
|
393
|
+
const isPre = eventName === "PreToolUse";
|
|
394
|
+
|
|
395
|
+
// AskUserQuestion is the one tool whose PRE event matters: the agent is
|
|
396
|
+
// about to block on a question, and that is precisely when a human needs
|
|
397
|
+
// to see it. Its payload is also the reason the activity row alone is not
|
|
398
|
+
// enough — options are truncated there, and the row only lands on Post,
|
|
399
|
+
// by which time the question is already answered.
|
|
400
|
+
if (toolName === "AskUserQuestion") {
|
|
401
|
+
const sessionId = readSessionId(claudeSessionId);
|
|
402
|
+
if (sessionId != null) {
|
|
403
|
+
if (isPre) {
|
|
404
|
+
await reportPrompt(profile, sessionId, event.tool_input || event.toolInput);
|
|
405
|
+
} else {
|
|
406
|
+
await post(profile, `/api/taskflow/agents/sessions/${sessionId}/prompt/clear`, {});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// #48: the tool ran, so whatever approval was blocking it is resolved —
|
|
411
|
+
// retire the dashboard prompt. Gated on a local flag so the usual case
|
|
412
|
+
// (no prompt outstanding) costs a file read, not a request.
|
|
413
|
+
if (!isPre && toolName !== "AskUserQuestion" && readPermissionPending(claudeSessionId)) {
|
|
414
|
+
const sessionId = readSessionId(claudeSessionId);
|
|
415
|
+
if (sessionId != null) {
|
|
416
|
+
await post(profile, `/api/taskflow/agents/sessions/${sessionId}/prompt/clear`, {});
|
|
417
|
+
}
|
|
418
|
+
setPermissionPending(claudeSessionId, false);
|
|
419
|
+
}
|
|
420
|
+
// Log ONCE per tool call, on completion. Logging both phases doubled every
|
|
421
|
+
// row in the activity feed (a 50-tool session read as 100 events), and the
|
|
422
|
+
// pre/post distinction lived only in metadata the UI never surfaces.
|
|
423
|
+
// PreToolUse still heartbeats — that's what keeps the agent showing as
|
|
424
|
+
// busy while a long tool runs.
|
|
425
|
+
// #56: log only tools worth a journal entry. The HEARTBEAT below is
|
|
426
|
+
// deliberately outside this gate — it is what keeps the agent showing as
|
|
427
|
+
// busy during a long run of Reads and Bashes, and skipping it would make a
|
|
428
|
+
// working agent look idle.
|
|
429
|
+
if (!isPre && shouldLogTool(toolName)) {
|
|
430
|
+
await post(profile, "/api/taskflow/agents/activity", {
|
|
431
|
+
action: `tool:${toolName}`,
|
|
432
|
+
body_markdown: "completed",
|
|
433
|
+
// toolName selects which field (if any) may be shortened. Everything
|
|
434
|
+
// else about the call is recorded in full.
|
|
435
|
+
metadata_json: compactMetadata(
|
|
436
|
+
{ input: event.tool_input || event.toolInput },
|
|
437
|
+
toolName,
|
|
438
|
+
),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
const sessionId = readSessionId(claudeSessionId);
|
|
442
|
+
if (sessionId != null) {
|
|
443
|
+
await post(profile, `/api/taskflow/agents/sessions/${sessionId}/heartbeat`, {
|
|
444
|
+
status: "busy",
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
} else if (eventName === "Stop" || eventName === "SubagentStop") {
|
|
448
|
+
const sessionId = readSessionId(claudeSessionId);
|
|
449
|
+
if (sessionId != null) {
|
|
450
|
+
// A finished TURN is not a finished SESSION. Closing here marked the
|
|
451
|
+
// agent `disconnected` seconds after every reply, so it read as offline
|
|
452
|
+
// for the whole time a human was actually reading that reply and typing
|
|
453
|
+
// back — which is precisely when they want to message it. The MCP
|
|
454
|
+
// connection is still open; the agent is idle, not gone.
|
|
455
|
+
//
|
|
456
|
+
// Nothing is lost by not closing: `isSessionLive` requires `connected`
|
|
457
|
+
// AND a heartbeat inside the window, so an agent that genuinely dies
|
|
458
|
+
// stops heartbeating and ages out on staleness. That is the honest
|
|
459
|
+
// signal — a dead process cannot announce its own death, which is why
|
|
460
|
+
// the close was never a reliable one anyway.
|
|
461
|
+
await post(profile, `/api/taskflow/agents/sessions/${sessionId}/heartbeat`, {
|
|
462
|
+
status: "idle",
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
await post(profile, "/api/taskflow/agents/activity", {
|
|
466
|
+
action: "session_stop",
|
|
467
|
+
});
|
|
468
|
+
} else if (eventName === "Notification") {
|
|
469
|
+
const message = event.message || event.notification || undefined;
|
|
470
|
+
await post(profile, "/api/taskflow/agents/activity", {
|
|
471
|
+
action: "notification",
|
|
472
|
+
body_markdown: message,
|
|
473
|
+
metadata_json: compactMetadata({ message: event.message }),
|
|
474
|
+
});
|
|
475
|
+
// #48: a tool approval blocks the agent exactly like AskUserQuestion does,
|
|
476
|
+
// but its hook payload carries no options — only this message. Surface it
|
|
477
|
+
// as a real prompt so it reaches the dashboard as a question instead of a
|
|
478
|
+
// passive activity row nobody is watching.
|
|
479
|
+
if (isPermissionNotification(message)) {
|
|
480
|
+
const sessionId = readSessionId(claudeSessionId);
|
|
481
|
+
if (sessionId != null) {
|
|
482
|
+
// Only flag a pending prompt when one was actually reported. Setting it
|
|
483
|
+
// unconditionally meant the next PostToolUse fired `prompt/clear`,
|
|
484
|
+
// which cancels whatever is pending — including a real AskUserQuestion
|
|
485
|
+
// this hook never raised. Same clobber, one step removed.
|
|
486
|
+
const reported = await reportPermissionPrompt(profile, sessionId, pane);
|
|
487
|
+
if (reported) setPermissionPending(claudeSessionId, true);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// Unknown events are ignored (still exit 0).
|
|
492
|
+
} catch (err) {
|
|
493
|
+
bail(`post error: ${err && err.message ? err.message : err}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
process.exit(0);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
main().catch((err) => bail(`fatal: ${err && err.message ? err.message : err}`));
|