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 — 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; 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 139 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
14type Sent = { answer: string; question: string; label: string }
15
16type Rows = Record<string, unknown>
17
18const isSent = (value: unknown): value is Sent => {
19 const { answer, question, label } = (value ?? {}) as Record<string, unknown>
20
21 return [answer, question, label].every(field => typeof field === 'string')
22}
23
24const isRows = (value: unknown): value is Rows =>
25 typeof value === 'object' && value !== null && !Array.isArray(value)
26
27// The engine names the folder so (domain/conversation.cjs), and the band with
28// it: in the system config directory, `WORKFLOWS_CONFIG_DIR` or else
29// `.config/workflows` in the home directory; none where neither is named.
30async function folderOf($: EngineInterface): Promise<string | null> {
31 const home = await $.env.get('HOME')
32 const config =
33 (await $.env.get('WORKFLOWS_CONFIG_DIR')) ||
34 (home ? `${home}/.config/workflows` : null)
35 const id = await $.session.id()
36
37 return config === null
38 ? null
39 : `${config}/${CONVERSATIONS}/${id.replace(/[^A-Za-z0-9_-]/g, '')}`
40}
41
42function lineOf({ answer, question, label }: Sent): string {
43 const answered = `${question} → ${answer}`
44
45 return label === '' || label === answer ? answered : `${answered} · ${label}`
46}
47
48function answerIn({
49 origin,
50 isExpanded,
51 text,
52}: RenderPropsOf['UserMessage']): string | null {
53 if (isExpanded || origin.kind !== 'plugin' || origin.name !== SENDER) {
54 return null
55 }
56
57 return FRAMED.exec(text)?.[1] ?? null
58}
59
60async function lastSent($: EngineInterface, folder: string): Promise<Sent | null> {
61 try {
62 const sent: unknown = JSON.parse(await $.fs.read(`${folder}/${SENT}`))
63
64 return isSent(sent) ? sent : null
65 } catch {
66 return null
67 }
68}
69
70async function keptIn($: EngineInterface, folder: string): Promise<Rows> {
71 try {
72 const rows: unknown = JSON.parse(await $.fs.read(`${folder}/${ROWS}`))
73
74 return isRows(rows) ? rows : {}
75 } catch {
76 return {}
77 }
78}
79
80async function firstLineOf(
81 $: EngineInterface,
82 requestId: string,
83 answer: string,
84): Promise<string | null> {
85 const folder = await folderOf($)
86
87 if (folder === null || !(await $.fs.exists(folder))) {
88 return null
89 }
90
91 const rows = await keptIn($, folder)
92 const kept = rows[requestId]
93
94 if (typeof kept === 'string') {
95 return kept
96 }
97
98 const sent = await lastSent($, folder)
99 const paired = sent?.answer === answer
100 const line = paired ? lineOf(sent) : answer
101
102 await $.fs.write(
103 `${folder}/${ROWS}`,
104 JSON.stringify({ ...rows, [requestId]: line }),
105 )
106
107 if (paired) {
108 await $.fs.write(`${folder}/${SENT}`, SPENT)
109 }
110
111 return line
112}
113
114export const register: Register = on => {
115 const lines = new Map<string, string | null>()
116
117 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
118 const answer = answerIn(e.props)
119
120 if (answer === null) {
121 return next(e)
122 }
123
124 let line = lines.get(e.requestId)
125
126 if (line === undefined) {
127 line = await firstLineOf($, e.requestId, answer)
128 lines.set(e.requestId, line)
129 }
130
131 if (line === null) {
132 return next(e)
133 }
134
135 // The drawing alone changes: the model reads Claude Code's framing, by design.
136 return next({ ...e, props: { ...e.props, text: line } })
137 }).catch(($, e, next) => next(e))
138}
139