@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.
Files changed (101) hide show
  1. package/README.md +138 -193
  2. package/dist/attachment-download.d.ts +74 -0
  3. package/dist/attachment-download.js +193 -0
  4. package/dist/attachment-download.js.map +1 -0
  5. package/dist/attachments.d.ts +23 -0
  6. package/dist/attachments.js +66 -0
  7. package/dist/attachments.js.map +1 -0
  8. package/dist/client.d.ts +206 -0
  9. package/dist/client.js +279 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +137 -18
  12. package/dist/config.js +187 -106
  13. package/dist/config.js.map +1 -0
  14. package/dist/connect.d.ts +89 -0
  15. package/dist/connect.js +269 -0
  16. package/dist/connect.js.map +1 -0
  17. package/dist/doctor.d.ts +24 -0
  18. package/dist/doctor.js +120 -0
  19. package/dist/doctor.js.map +1 -0
  20. package/dist/events.d.ts +186 -0
  21. package/dist/events.js +407 -0
  22. package/dist/events.js.map +1 -0
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.js +121 -218
  25. package/dist/index.js.map +1 -0
  26. package/dist/instructions.d.ts +12 -0
  27. package/dist/instructions.js +114 -0
  28. package/dist/instructions.js.map +1 -0
  29. package/dist/mint.d.ts +62 -0
  30. package/dist/mint.js +135 -0
  31. package/dist/mint.js.map +1 -0
  32. package/dist/mirror.d.ts +68 -0
  33. package/dist/mirror.js +103 -0
  34. package/dist/mirror.js.map +1 -0
  35. package/dist/pane-queue.d.ts +29 -0
  36. package/dist/pane-queue.js +35 -0
  37. package/dist/pane-queue.js.map +1 -0
  38. package/dist/prompts.d.ts +79 -0
  39. package/dist/prompts.js +211 -0
  40. package/dist/prompts.js.map +1 -0
  41. package/dist/resolve.d.ts +72 -0
  42. package/dist/resolve.js +89 -0
  43. package/dist/resolve.js.map +1 -0
  44. package/dist/runtime.d.ts +54 -0
  45. package/dist/runtime.js +321 -0
  46. package/dist/runtime.js.map +1 -0
  47. package/dist/server.d.ts +56 -0
  48. package/dist/server.js +793 -0
  49. package/dist/server.js.map +1 -0
  50. package/dist/session-identifier.d.ts +48 -0
  51. package/dist/session-identifier.js +44 -0
  52. package/dist/session-identifier.js.map +1 -0
  53. package/dist/sessions-store.d.ts +38 -0
  54. package/dist/sessions-store.js +88 -0
  55. package/dist/sessions-store.js.map +1 -0
  56. package/dist/tmux.d.ts +200 -0
  57. package/dist/tmux.js +580 -0
  58. package/dist/tmux.js.map +1 -0
  59. package/hooks/metadata.mjs +99 -0
  60. package/hooks/permission-prompt.mjs +100 -0
  61. package/hooks/taskflow-hook.mjs +499 -0
  62. package/hooks/tool-logging.mjs +63 -0
  63. package/package.json +38 -29
  64. package/dist/agent-registry.d.ts +0 -28
  65. package/dist/agent-registry.js +0 -158
  66. package/dist/db.d.ts +0 -5
  67. package/dist/db.js +0 -220
  68. package/dist/helpers.d.ts +0 -21
  69. package/dist/helpers.js +0 -27
  70. package/dist/resources.d.ts +0 -2
  71. package/dist/resources.js +0 -89
  72. package/dist/retry.d.ts +0 -34
  73. package/dist/retry.js +0 -94
  74. package/dist/sse.d.ts +0 -10
  75. package/dist/sse.js +0 -824
  76. package/dist/tmux-bridge.d.ts +0 -13
  77. package/dist/tmux-bridge.js +0 -217
  78. package/dist/tools/activity.d.ts +0 -39
  79. package/dist/tools/activity.js +0 -152
  80. package/dist/tools/agent-inbox.d.ts +0 -12
  81. package/dist/tools/agent-inbox.js +0 -272
  82. package/dist/tools/agent.d.ts +0 -14
  83. package/dist/tools/agent.js +0 -168
  84. package/dist/tools/analytics.d.ts +0 -21
  85. package/dist/tools/analytics.js +0 -191
  86. package/dist/tools/checkpoint.d.ts +0 -27
  87. package/dist/tools/checkpoint.js +0 -105
  88. package/dist/tools/notifications.d.ts +0 -31
  89. package/dist/tools/notifications.js +0 -59
  90. package/dist/tools/projects.d.ts +0 -55
  91. package/dist/tools/projects.js +0 -112
  92. package/dist/tools/settings.d.ts +0 -19
  93. package/dist/tools/settings.js +0 -73
  94. package/dist/tools/tasks.d.ts +0 -105
  95. package/dist/tools/tasks.js +0 -403
  96. package/dist/tools/terminal.d.ts +0 -4
  97. package/dist/tools/terminal.js +0 -98
  98. package/dist/tools/timer.d.ts +0 -37
  99. package/dist/tools/timer.js +0 -154
  100. package/dist/types.d.ts +0 -83
  101. 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}`));