Draws the transcript row of an answer the workflow-gates mod sent as the question it answered and the answer, instead of Claude Code's framing.

A Claude Code mod that draws the transcript row of an answer the workflow-gates mod sent as the question and the answer — Approve this task? → yes · Commit and continue to next task — and the row of a handoff's continuation as the line naming where the work went — → Discussion · note-window — in place of Claude Code's framing of a plugin's prompt. It is a plugin of its own because Claude Code skips a plugin's own render hooks on the row of a prompt that plugin submitted. It pairs a row with the record the mod leaves in the conversation's own folder as sent.json as it sends, drawing the record's line verbatim where it carries one, the handoff's; a row it cannot pair shows the answer alone. It changes only the drawing: the model reads Claude Code's framing, and ctrl+o shows the message in full.
Each line it draws is kept in that folder, ~/.config/workflows/conversations/{session-id}/rows.json (under WORKFLOWS_CONFIG_DIR where that is set, beside the workflows' system config), under the row's message id, so scrolling back and a resumed conversation draw the row the same way, wherever the session's working directory has moved. The folder goes once Claude Code has deleted the conversation's transcript, and the lines with it, with one exception: a conversation whose end the session-end hook never saw — the session that first installed the hook, or one that crashed — keeps its folder. The mod names the folder the way the workflows' engine does. A conversation that does not run the workflows has no folder, nor does one in a process that names neither a home directory nor WORKFLOWS_CONFIG_DIR: there the mod writes nothing and draws the row as Claude Code does.
hooks/register.ts 152 lines1// A plugin of its own: Claude Code skips the mod's render hooks on rows it sent.
2import type { EngineInterface, RenderPropsOf, Register } from 'claude-code'
3
4const SENDER = 'workflow-gates'
5
6const SENT = 'sent.json'
7const SPENT = 'null'
8
9const CONVERSATIONS = 'conversations'
10const ROWS = 'rows.json'
11
12const FRAMED = /sent a message:\n([\s\S]+?)\n\nThis is how Claude Code surfaces a prompt/
13
14// A gate's answer, or a handoff's continuation with the line it draws as.
15type Sent =
16 | { answer: string; question: string; label: string }
17 | { answer: string; line: string }
18
19type Rows = Record<string, unknown>
20
21const isText = (field: unknown) => typeof field === 'string'
22
23const isSent = (value: unknown): value is Sent => {
24 const sent = (value ?? {}) as Record<string, unknown>
25
26 return (
27 isText(sent.answer) &&
28 (isText(sent.line) || (isText(sent.question) && isText(sent.label)))
29 )
30}
31
32const isRows = (value: unknown): value is Rows =>
33 typeof value === 'object' && value !== null && !Array.isArray(value)
34
35// The engine names the folder so (domain/conversation.cjs), and the band with
36// it: in the system config directory, `WORKFLOWS_CONFIG_DIR` or else
37// `.config/workflows` in the home directory; none where neither is named.
38async function folderOf($: EngineInterface): Promise<string | null> {
39 const home = await $.env.get('HOME')
40 const config =
41 (await $.env.get('WORKFLOWS_CONFIG_DIR')) ||
42 (home ? `${home}/.config/workflows` : null)
43 const id = await $.session.id()
44
45 return config === null
46 ? null
47 : `${config}/${CONVERSATIONS}/${id.replace(/[^A-Za-z0-9_-]/g, '')}`
48}
49
50function lineOf(sent: Sent): string {
51 if ('line' in sent) {
52 return sent.line
53 }
54
55 const { answer, question, label } = sent
56 const answered = `${question} → ${answer}`
57
58 return label === '' || label === answer ? answered : `${answered} · ${label}`
59}
60
61function answerIn({
62 origin,
63 isExpanded,
64 text,
65}: RenderPropsOf['UserMessage']): string | null {
66 if (isExpanded || origin.kind !== 'plugin' || origin.name !== SENDER) {
67 return null
68 }
69
70 return FRAMED.exec(text)?.[1] ?? null
71}
72
73async function lastSent($: EngineInterface, folder: string): Promise<Sent | null> {
74 try {
75 const sent: unknown = JSON.parse(await $.fs.read(`${folder}/${SENT}`))
76
77 return isSent(sent) ? sent : null
78 } catch {
79 return null
80 }
81}
82
83async function keptIn($: EngineInterface, folder: string): Promise<Rows> {
84 try {
85 const rows: unknown = JSON.parse(await $.fs.read(`${folder}/${ROWS}`))
86
87 return isRows(rows) ? rows : {}
88 } catch {
89 return {}
90 }
91}
92
93async function firstLineOf(
94 $: EngineInterface,
95 requestId: string,
96 answer: string,
97): Promise<string | null> {
98 const folder = await folderOf($)
99
100 if (folder === null || !(await $.fs.exists(folder))) {
101 return null
102 }
103
104 const rows = await keptIn($, folder)
105 const kept = rows[requestId]
106
107 if (typeof kept === 'string') {
108 return kept
109 }
110
111 const sent = await lastSent($, folder)
112 const paired = sent?.answer === answer
113 const line = paired ? lineOf(sent) : answer
114
115 await $.fs.write(
116 `${folder}/${ROWS}`,
117 JSON.stringify({ ...rows, [requestId]: line }),
118 )
119
120 if (paired) {
121 await $.fs.write(`${folder}/${SENT}`, SPENT)
122 }
123
124 return line
125}
126
127export const register: Register = on => {
128 const lines = new Map<string, string | null>()
129
130 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
131 const answer = answerIn(e.props)
132
133 if (answer === null) {
134 return next(e)
135 }
136
137 let line = lines.get(e.requestId)
138
139 if (line === undefined) {
140 line = await firstLineOf($, e.requestId, answer)
141 lines.set(e.requestId, line)
142 }
143
144 if (line === null) {
145 return next(e)
146 }
147
148 // The drawing alone changes: the model reads Claude Code's framing, by design.
149 return next({ ...e, props: { ...e.props, text: line } })
150 }).catch(($, e, next) => next(e))
151}
152