From 101aaac1ad8a28808de49778738f254d4bd36a7f Mon Sep 17 00:00:00 2001 From: nusquama Date: Sun, 15 Mar 2026 12:01:11 +0800 Subject: [PATCH] creation --- ...laude_code_sessions_from_matrix_with_youtrack_and_gitlab.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 workflows/Manage Claude Code sessions from Matrix with YouTrack and GitLab-13943/manage_claude_code_sessions_from_matrix_with_youtrack_and_gitlab.json diff --git a/workflows/Manage Claude Code sessions from Matrix with YouTrack and GitLab-13943/manage_claude_code_sessions_from_matrix_with_youtrack_and_gitlab.json b/workflows/Manage Claude Code sessions from Matrix with YouTrack and GitLab-13943/manage_claude_code_sessions_from_matrix_with_youtrack_and_gitlab.json new file mode 100644 index 000000000..7ef3122f9 --- /dev/null +++ b/workflows/Manage Claude Code sessions from Matrix with YouTrack and GitLab-13943/manage_claude_code_sessions_from_matrix_with_youtrack_and_gitlab.json @@ -0,0 +1 @@ +{"id":"UGhTOwk0sz0gm8PU","meta":{"instanceId":"0dd3ae188cfd2b96467338af7f80668f3761b218fde5df5eff29cb57f9644576"},"name":"Manage AI coding sessions from Matrix with YouTrack and GitLab","tags":[],"nodes":[{"id":"7c31b18c","name":"Sticky Note","type":"n8n-nodes-base.stickyNote","position":[208,-352],"parameters":{"color":4,"width":540,"height":520,"content":"## Manage AI coding sessions from Matrix\nA chat-ops bridge between Matrix, Claude Code,\nYouTrack, and GitLab. Your team talks to an AI\ncoding assistant from a chat room — with issue\ntracking and CI/CD visibility built in.\n\n## How it works\n1. Polls a Matrix room every 30s for messages\n2. Routes `!commands` to the matching handler\n3. Forwards messages to Claude Code via SSH\n4. Posts Claude's response back to Matrix\n5. Syncs session state with YouTrack issues\n\n## Setup steps\n1. Import and edit the **Gateway Config** node\n2. Create n8n credentials: SSH + Matrix token\n3. Set `YOUTRACK_TOKEN` and `GITLAB_TOKEN`\n4. Create the SQLite database (see description)\n5. Activate the workflow"},"typeVersion":1},{"id":"4ceda743","name":"Sticky Note 4fa9","type":"n8n-nodes-base.stickyNote","position":[176,224],"parameters":{"width":260,"height":228,"content":"### 1. Configuration\nUser-configurable variables."},"typeVersion":1},{"id":"277ed962","name":"Sticky Note 7417","type":"n8n-nodes-base.stickyNote","position":[464,224],"parameters":{"width":960,"height":228,"content":"### 2. Matrix polling\nPolls Matrix /sync every 30 seconds, extracts new messages, filters empty batches."},"typeVersion":1},{"id":"874e52f1","name":"Sticky Note 782c","type":"n8n-nodes-base.stickyNote","position":[1472,224],"parameters":{"width":500,"height":420,"content":"### 3. Command routing\nParses `!commands`, routes to handlers."},"typeVersion":1},{"id":"8489758d","name":"Sticky Note c9e3","type":"n8n-nodes-base.stickyNote","position":[2016,-304],"parameters":{"width":1576,"height":374,"content":"### 4. Message handler\nChecks lock, reads session, resumes Claude via SSH, posts response to Matrix."},"typeVersion":1},{"id":"2e04a109","name":"Sticky Note 6e7b","type":"n8n-nodes-base.stickyNote","position":[2016,128],"parameters":{"width":592,"height":1248,"content":"### 5. Command handlers\n**!session** current, list, done, cancel, pause, resume\n**!issue** status, info, start, verify, done, comment\n**!pipeline** status, logs, retry\n**!system** status | **!help** reference"},"typeVersion":1},{"id":"7c36c474","name":"Sticky Note dbbd","type":"n8n-nodes-base.stickyNote","position":[2656,496],"parameters":{"width":760,"height":288,"content":"### 6. Response and session end\nPosts output to Matrix. Archives session to SQLite on done."},"typeVersion":1},{"id":"gateway-config","name":"Gateway Config","type":"n8n-nodes-base.set","position":[208,304],"parameters":{"options":{},"assignments":{"assignments":[{"id":"ssh-host","name":"SSH_HOST","type":"string","value":"your-server-hostname"},{"id":"e402df7c","name":"MATRIX_HOMESERVER","type":"string","value":"https://your-matrix-server.com"},{"id":"7a2638f9","name":"MATRIX_ROOM_ID","type":"string","value":"!yourRoomId:your-matrix-server.com"},{"id":"f020d2f8","name":"MATRIX_BOT_USER","type":"string","value":"@bot:your-matrix-server.com"},{"id":"dc840315","name":"YOUTRACK_URL","type":"string","value":"https://your-youtrack-instance.com"},{"id":"0e88ef8d","name":"GITLAB_URL","type":"string","value":"https://your-gitlab-instance.com"},{"id":"0ef605c8","name":"CLAUDE_PROJECT_PATH","type":"string","value":"/home/user/your-project"},{"id":"e0482d36","name":"CLAUDE_BINARY","type":"string","value":"/home/user/.local/bin/claude"},{"id":"690b2fc6","name":"DB_PATH","type":"string","value":"/home/user/your-project/claude-context/gateway.db"},{"id":"e8bcc6ff","name":"CONTEXT_DIR","type":"string","value":"/home/user/your-project/claude-context"},{"id":"393a34b8","name":"ISSUE_PREFIX","type":"string","value":"PROJ"},{"id":"8d185b9c","name":"COOLDOWN_TTL","type":"string","value":"30"}]}},"typeVersion":3.4},{"id":"poll-trigger","name":"Poll Every 30s","type":"n8n-nodes-base.scheduleTrigger","position":[512,304],"parameters":{"rule":{"interval":[{"field":"seconds"}]}},"typeVersion":1.2},{"id":"get-sync-token","name":"Get Sync Token","type":"n8n-nodes-base.code","position":[704,304],"parameters":{"jsCode":"const staticData = $getWorkflowStaticData('global');\nconst sinceToken = staticData.matrixSinceToken || '';\nreturn [{ json: { sinceToken, ...$('Gateway Config').first().json } }];"},"typeVersion":2},{"id":"poll-matrix","name":"Poll Matrix Sync","type":"n8n-nodes-base.httpRequest","position":[912,304],"parameters":{"url":"={{ $json.MATRIX_HOMESERVER }}/_matrix/client/v3/sync?timeout=0&filter={\"room\":{\"rooms\":[\"{{ $json.MATRIX_ROOM_ID }}\"],\"timeline\":{\"limit\":10}}}{{ $json.sinceToken ? '&since=' + $json.sinceToken : '' }}","options":{"timeout":15000},"authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth"},"credentials":{"httpHeaderAuth":{"id":"MATRIX_CRED_ID","name":"Matrix Bot Token"}},"typeVersion":4.2},{"id":"extract-messages","name":"Extract Messages","type":"n8n-nodes-base.code","position":[1104,304],"parameters":{"jsCode":"const config = $('Gateway Config').first().json;\nconst syncData = $input.first().json;\nconst staticData = $getWorkflowStaticData('global');\n\n// Save sync token for next poll\nif (syncData.next_batch) staticData.matrixSinceToken = syncData.next_batch;\n\n// Extract messages from timeline\nconst roomId = config.MATRIX_ROOM_ID;\nconst rooms = syncData.rooms?.join || {};\nconst room = rooms[roomId] || syncData.rooms?.join?.[Object.keys(syncData.rooms?.join || {})[0]];\nconst events = room?.timeline?.events || [];\n\nconst lastTs = staticData.lastProcessedTimestamp || 0;\nconst botUser = config.MATRIX_BOT_USER;\n\nconst messages = events\n .filter(e => e.type === 'm.room.message'\n && e.sender !== botUser\n && e.origin_server_ts > lastTs)\n .map(e => ({\n messageText: e.content?.body || '',\n sender: e.sender,\n timestamp: e.origin_server_ts\n }));\n\nif (messages.length > 0) {\n staticData.lastProcessedTimestamp = Math.max(...messages.map(m => m.timestamp));\n}\n\nreturn messages.length > 0\n ? [{ json: { ...messages[0], ...config } }]\n : [];"},"typeVersion":2},{"id":"has-messages","name":"Has Messages?","type":"n8n-nodes-base.if","position":[1312,304],"parameters":{"options":{},"conditions":{"options":{"version":2,"leftValue":"","caseSensitive":true,"typeValidation":"strict"},"combinator":"and","conditions":[{"id":"c8b1ae50","operator":{"type":"string","operation":"isNotEmpty","singleValue":true},"leftValue":"={{ $json.messageText }}","rightValue":""}]}},"typeVersion":2},{"id":"detect-command","name":"Detect Command","type":"n8n-nodes-base.code","position":[1504,304],"parameters":{"jsCode":"const config = $('Gateway Config').first().json;\nconst messageText = $input.first().json.messageText || '';\nconst trimmed = messageText.trim();\nconst lower = trimmed.toLowerCase();\nconst base64Message = Buffer.from(messageText).toString('base64');\n\n// Check for PREFIX: message format (e.g. PROJ-4: do something)\nconst prefixMatch = trimmed.match(/^([A-Z]+-\\d+):\\s*([\\s\\S]*)$/);\nif (prefixMatch) {\n const prefix = prefixMatch[1];\n const strippedText = prefixMatch[2].trim();\n if (!strippedText) {\n return [{ json: { ...config, command: 'empty', matrixBody: JSON.stringify({ msgtype: 'm.notice', body: 'Message body is empty. Usage: ' + prefix + ': ' }) } }];\n }\n const base64Stripped = Buffer.from(strippedText).toString('base64');\n return [{ json: { ...config, command: 'message', sub: '', args: [], messageText: strippedText, base64Message: base64Stripped, prefix } }];\n}\n\nif (!lower.startsWith('!')) {\n if (!trimmed) return [{ json: { ...config, command: 'empty', matrixBody: JSON.stringify({ msgtype: 'm.notice', body: 'Message cannot be empty.' }) } }];\n return [{ json: { ...config, command: 'message', sub: '', args: [], messageText, base64Message, prefix: '' } }];\n}\n\nconst lowerParts = lower.slice(1).split(/\\s+/);\nconst origParts = trimmed.slice(1).split(/\\s+/);\nconst command = lowerParts[0] || '';\nconst sub = lowerParts[1] || '';\nconst args = origParts.slice(2);\n\n// Legacy aliases\nconst resolved = { done: 'session', cancel: 'session', status: 'session' }[command] || command;\nconst resolvedSub = { done: 'done', cancel: 'cancel', status: 'current' }[command] || sub;\n\nreturn [{ json: { ...config, command: resolved, sub: resolvedSub, args, messageText, base64Message, prefix: '' } }];"},"typeVersion":2},{"id":"command-router","name":"Command Router","type":"n8n-nodes-base.switch","position":[1760,304],"parameters":{"rules":{"values":[{"outputKey":"message","conditions":{"combinator":"and","conditions":[{"operator":{"type":"string","operation":"equals"},"leftValue":"={{ $json.command }}","rightValue":"message"}]},"renameOutput":true},{"outputKey":"session","conditions":{"combinator":"and","conditions":[{"operator":{"type":"string","operation":"equals"},"leftValue":"={{ $json.command }}","rightValue":"session"}]},"renameOutput":true},{"outputKey":"help","conditions":{"combinator":"and","conditions":[{"operator":{"type":"string","operation":"equals"},"leftValue":"={{ $json.command }}","rightValue":"help"}]},"renameOutput":true},{"outputKey":"issue","conditions":{"combinator":"and","conditions":[{"operator":{"type":"string","operation":"equals"},"leftValue":"={{ $json.command }}","rightValue":"issue"}]},"renameOutput":true},{"outputKey":"pipeline","conditions":{"combinator":"and","conditions":[{"operator":{"type":"string","operation":"equals"},"leftValue":"={{ $json.command }}","rightValue":"pipeline"}]},"renameOutput":true},{"outputKey":"system","conditions":{"combinator":"and","conditions":[{"operator":{"type":"string","operation":"equals"},"leftValue":"={{ $json.command }}","rightValue":"system"}]},"renameOutput":true}]},"options":{"fallbackOutput":"extra"}},"typeVersion":3},{"id":"check-lock","name":"Check Lock","type":"n8n-nodes-base.ssh","position":[2064,-112],"parameters":{"command":"={{ 'GW=\"' + $json.CONTEXT_DIR + '\" && if [ -f $GW/gateway.lock ] && [ $(( $(date +%s) - $(stat -c %Y $GW/gateway.lock) )) -lt 600 ]; then echo LOCKED; else rm -f $GW/gateway.lock && echo FREE; fi' }}","authentication":"privateKey"},"credentials":{"sshPrivateKey":{"id":"SSH_CRED_ID","name":"Server SSH Key"}},"typeVersion":1},{"id":"is-locked","name":"Is Locked?","type":"n8n-nodes-base.if","position":[2272,-112],"parameters":{"options":{},"conditions":{"options":{"version":2,"leftValue":"","caseSensitive":true,"typeValidation":"strict"},"combinator":"and","conditions":[{"id":"02be178d","operator":{"type":"string","operation":"equals"},"leftValue":"={{ $json.stdout.trim() }}","rightValue":"LOCKED"}]}},"typeVersion":2},{"id":"post-busy","name":"Post Busy Notice","type":"n8n-nodes-base.httpRequest","position":[2608,-240],"parameters":{"url":"={{ $('Gateway Config').first().json.MATRIX_HOMESERVER }}/_matrix/client/v3/rooms/{{ $('Gateway Config').first().json.MATRIX_ROOM_ID }}/send/m.room.message/busy-{{ Date.now() }}","method":"PUT","options":{},"jsonBody":"={{ JSON.stringify({ msgtype: 'm.notice', body: 'Claude is busy processing a previous message. Your message has been queued.' }) }}","sendBody":true,"specifyBody":"json","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth"},"credentials":{"httpHeaderAuth":{"id":"MATRIX_CRED_ID","name":"Matrix Bot Token"}},"typeVersion":4.2},{"id":"read-session","name":"Read Session & Acquire Lock","type":"n8n-nodes-base.ssh","position":[2496,-112],"parameters":{"command":"={{ 'DB=\"' + $('Gateway Config').first().json.DB_PATH + '\" && GW=\"' + $('Gateway Config').first().json.CONTEXT_DIR + '\" && echo locked > $GW/gateway.lock && sqlite3 $DB \"SELECT json_object(\\'sessionId\\',session_id,\\'issueId\\',issue_id,\\'issueTitle\\',issue_title) FROM sessions WHERE is_current=1 LIMIT 1;\" 2>/dev/null' }}","authentication":"privateKey"},"credentials":{"sshPrivateKey":{"id":"SSH_CRED_ID","name":"Server SSH Key"}},"typeVersion":1,"continueOnFail":true},{"id":"resume-claude","name":"Resume Claude Session","type":"n8n-nodes-base.ssh","position":[2720,-112],"parameters":{"command":"=CLAUDE_BIN=\"{{ $(\"Gateway Config\").first().json.CLAUDE_BINARY }}\"\nPROJECT=\"{{ $(\"Gateway Config\").first().json.CLAUDE_PROJECT_PATH }}\"\nSESSION_RAW=\"{{ $(\"Read Session & Acquire Lock\").first().json.stdout || '' }}\"\nMSG_B64=\"{{ $(\"Detect Command\").first().json.base64Message }}\"\n\nSESSION_RAW=$(echo \"$SESSION_RAW\" | tr -d '\\n')\n[ -z \"$SESSION_RAW\" ] && echo \"NO_SESSION\" && exit 0\n\nSID=$(echo \"$SESSION_RAW\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('sessionId',''))\" 2>/dev/null)\n[ -z \"$SID\" ] && echo \"NO_SESSION\" && exit 0\n\nunset CLAUDECODE\ncd \"$PROJECT\"\ntimeout 300 \"$CLAUDE_BIN\" -r \"$SID\" -p \"$(printf '%s' \"$MSG_B64\" | base64 -d)\" --output-format json --dangerously-skip-permissions 2>&1","authentication":"privateKey"},"credentials":{"sshPrivateKey":{"id":"SSH_CRED_ID","name":"Server SSH Key"}},"typeVersion":1,"continueOnFail":true},{"id":"parse-response","name":"Parse Claude Response","type":"n8n-nodes-base.code","position":[2944,-112],"parameters":{"jsCode":"const config = $('Gateway Config').first().json;\nconst stdout = $input.first().json.stdout || '';\nconst stderr = $input.first().json.stderr || '';\nconst output = (stdout.trim() || stderr.trim());\n\nif (output === 'NO_SESSION') {\n const body = 'No active Claude session. Start one via YouTrack or !issue start .';\n return [{ json: { matrixBody: JSON.stringify({ msgtype: 'm.notice', body }), ...config } }];\n}\n\nlet result;\ntry {\n const parsed = JSON.parse(output);\n result = parsed.result || output.substring(0, 4000);\n} catch(e) {\n result = output.substring(0, 4000) || 'No response from Claude.';\n}\n\nconst body = result;\nconst matrixBody = JSON.stringify({ msgtype: 'm.text', body });\nreturn [{ json: { matrixBody, ...config } }];"},"typeVersion":2},{"id":"post-response","name":"Post Response to Matrix","type":"n8n-nodes-base.httpRequest","position":[3152,-112],"parameters":{"url":"={{ $json.MATRIX_HOMESERVER }}/_matrix/client/v3/rooms/{{ $json.MATRIX_ROOM_ID }}/send/m.room.message/resp-{{ Date.now() }}","method":"PUT","options":{},"jsonBody":"={{ $json.matrixBody }}","sendBody":true,"specifyBody":"json","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth"},"credentials":{"httpHeaderAuth":{"id":"MATRIX_CRED_ID","name":"Matrix Bot Token"}},"typeVersion":4.2},{"id":"release-lock","name":"Release Lock","type":"n8n-nodes-base.ssh","position":[3376,-112],"parameters":{"command":"={{ 'rm -f \"' + $('Gateway Config').first().json.CONTEXT_DIR + '/gateway.lock\"' }}","authentication":"privateKey"},"credentials":{"sshPrivateKey":{"id":"SSH_CRED_ID","name":"Server SSH Key"}},"typeVersion":1,"continueOnFail":true},{"id":"handle-session","name":"Handle Session Command","type":"n8n-nodes-base.ssh","position":[2064,272],"parameters":{"command":"=SUB=\"{{ $(\"Detect Command\").first().json.sub || 'current' }}\"\nRAW_ARG0=\"{{ $(\"Detect Command\").first().json.args[0] || '' }}\"\nDB=\"{{ $(\"Gateway Config\").first().json.DB_PATH }}\"\nGW=\"{{ $(\"Gateway Config\").first().json.CONTEXT_DIR }}\"\n\nARG0=$(echo \"$RAW_ARG0\" | tr '[:lower:]' '[:upper:]')\n\ncase \"$SUB\" in\n current)\n ROW=$(sqlite3 \"$DB\" \"SELECT json_object('issue_id',issue_id,'session_id',session_id,'started_at',started_at,'message_count',message_count,'paused',paused) FROM sessions WHERE is_current=1 LIMIT 1;\")\n echo \"SESSION:${ROW:-NO_SESSION}\"\n ;;\n list)\n sqlite3 \"$DB\" \"SELECT json_group_array(json_object('issue_id',issue_id,'started_at',started_at,'message_count',message_count,'paused',paused,'is_current',is_current)) FROM sessions ORDER BY is_current DESC;\" 2>/dev/null\n ;;\n done)\n if [ -n \"$ARG0\" ]; then W=\"issue_id='$ARG0'\"; else W=\"is_current=1\"; fi\n sqlite3 \"$DB\" \"SELECT json_object('session_id',session_id,'issue_id',issue_id,'issue_title',issue_title) FROM sessions WHERE $W LIMIT 1;\" 2>/dev/null\n ;;\n cancel)\n if [ -n \"$ARG0\" ]; then W=\"issue_id='$ARG0'\"; else W=\"is_current=1\"; fi\n ISSUE=$(sqlite3 \"$DB\" \"SELECT issue_id FROM sessions WHERE $W LIMIT 1;\")\n if [ -n \"$ISSUE\" ]; then\n pkill -f claude 2>/dev/null\n rm -f \"$GW/gateway.lock\"\n sqlite3 \"$DB\" \"DELETE FROM sessions WHERE issue_id='$ISSUE'; DELETE FROM queue WHERE issue_id='$ISSUE';\"\n echo \"CANCELLED:$ISSUE\"\n else\n echo \"NO_SESSION\"\n fi\n ;;\n pause)\n if [ -n \"$ARG0\" ]; then W=\"issue_id='$ARG0'\"; else W=\"is_current=1\"; fi\n ISSUE=$(sqlite3 \"$DB\" \"SELECT issue_id FROM sessions WHERE $W LIMIT 1;\")\n if [ -n \"$ISSUE\" ]; then\n sqlite3 \"$DB\" \"UPDATE sessions SET paused=1 WHERE issue_id='$ISSUE';\"\n echo \"PAUSED:$ISSUE\"\n else\n echo \"NO_SESSION\"\n fi\n ;;\n resume)\n if [ -n \"$ARG0\" ]; then W=\"issue_id='$ARG0'\"; else W=\"is_current=1\"; fi\n ISSUE=$(sqlite3 \"$DB\" \"SELECT issue_id FROM sessions WHERE $W LIMIT 1;\")\n if [ -n \"$ISSUE\" ]; then\n sqlite3 \"$DB\" \"UPDATE sessions SET paused=0 WHERE issue_id='$ISSUE';\"\n echo \"RESUMED:$ISSUE\"\n else\n echo \"NO_SESSION\"\n fi\n ;;\n *) echo \"UNKNOWN_SUB:$SUB\" ;;\nesac","authentication":"privateKey"},"credentials":{"sshPrivateKey":{"id":"SSH_CRED_ID","name":"Server SSH Key"}},"typeVersion":1,"continueOnFail":true},{"id":"format-session","name":"Format Session Response","type":"n8n-nodes-base.code","position":[2272,272],"parameters":{"jsCode":"const config = $('Gateway Config').first().json;\nconst stdout = $input.first().json.stdout || '';\nconst sub = $('Detect Command').first().json.sub || 'current';\nconst output = stdout.trim();\nlet body = '';\n\nif (sub === 'current') {\n const raw = output.replace('SESSION:', '');\n if (!raw || raw === 'NO_SESSION') body = 'No active session.';\n else {\n try {\n const s = JSON.parse(raw);\n body = 'Current session:\\nIssue: ' + s.issue_id + '\\nMessages: ' + s.message_count + '\\nPaused: ' + (s.paused ? 'Yes' : 'No');\n } catch(e) { body = 'Error: ' + raw.substring(0, 200); }\n }\n} else if (sub === 'list') {\n try {\n const rows = JSON.parse(output || '[]');\n body = rows.length ? rows.map((s, i) => (i+1) + '. ' + s.issue_id + (s.is_current ? ' (current)' : '')).join('\\n') : 'No active sessions.';\n } catch(e) { body = 'Error: ' + output.substring(0, 200); }\n} else if (sub === 'done') {\n if (!output) body = 'No active session to end.';\n else {\n try { const s = JSON.parse(output); body = s.session_id ? 'Ending session for ' + s.issue_id + '...' : 'No active session.'; } catch(e) { body = 'No active session.'; }\n }\n} else if (sub === 'cancel') {\n body = output.startsWith('CANCELLED:') ? 'Session ' + output.split(':')[1] + ' cancelled.' : output === 'NO_SESSION' ? 'No session to cancel.' : output;\n} else if (sub === 'pause') {\n body = output.startsWith('PAUSED:') ? 'Session ' + output.split(':')[1] + ' paused.' : output === 'NO_SESSION' ? 'No session to pause.' : output;\n} else if (sub === 'resume') {\n body = output.startsWith('RESUMED:') ? 'Session ' + output.split(':')[1] + ' resumed.' : output === 'NO_SESSION' ? 'No session to resume.' : output;\n} else { body = 'Unknown: ' + sub; }\n\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"},"typeVersion":2},{"id":"handle-issue","name":"Handle Issue Command","type":"n8n-nodes-base.ssh","position":[2064,480],"parameters":{"command":"=SUB=\"{{ $(\"Detect Command\").first().json.sub || 'status' }}\"\nRAW_ARG0=\"{{ $(\"Detect Command\").first().json.args[0] || '' }}\"\nYT_URL=\"{{ $(\"Gateway Config\").first().json.YOUTRACK_URL }}\"\n\nARG0=$(echo \"$RAW_ARG0\" | tr '[:lower:]' '[:upper:]')\n\ncase \"$SUB\" in\n status)\n curl -s --max-time 10 \\\n -H \"Authorization: Bearer $YT_TOKEN\" \\\n \"$YT_URL/api/issues?query=State:+%7BIn+Progress%7D&fields=idReadable,summary,customFields(name,value(name))\" 2>&1\n ;;\n info)\n [ -z \"$ARG0\" ] && echo \"USAGE: !issue info \" && exit 0\n curl -s --max-time 10 \\\n -H \"Authorization: Bearer $YT_TOKEN\" \\\n \"$YT_URL/api/issues/$ARG0?fields=idReadable,summary,description,customFields(name,value(name))\" 2>&1\n ;;\n *) echo \"Available: !issue status, !issue info \" ;;\nesac","authentication":"privateKey"},"credentials":{"sshPrivateKey":{"id":"SSH_CRED_ID","name":"Server SSH Key"}},"typeVersion":1,"continueOnFail":true},{"id":"format-issue","name":"Format Issue Response","type":"n8n-nodes-base.code","position":[2272,432],"parameters":{"jsCode":"const config = $('Gateway Config').first().json;\nconst stdout = $input.first().json.stdout || '';\nconst output = stdout.trim();\nlet body = '';\n\ntry {\n const data = JSON.parse(output);\n if (Array.isArray(data)) {\n body = data.length === 0 ? 'No issues in progress.' : data.map(i => i.idReadable + ': ' + (i.summary || '—')).join('\\n');\n } else if (data.idReadable) {\n body = data.idReadable + ': ' + (data.summary || '—') + '\\n' + (data.description || 'No description').substring(0, 500);\n } else { body = output.substring(0, 1000); }\n} catch(e) { body = output || 'No response from YouTrack.'; }\n\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"},"typeVersion":2},{"id":"handle-pipeline","name":"Handle Pipeline Command","type":"n8n-nodes-base.ssh","position":[2064,640],"parameters":{"command":"=SUB=\"{{ $(\"Detect Command\").first().json.sub || 'status' }}\"\nGL_URL=\"{{ $(\"Gateway Config\").first().json.GITLAB_URL }}\"\n\ncase \"$SUB\" in\n status)\n curl -s --max-time 10 \\\n -H \"PRIVATE-TOKEN: $GL_TOKEN\" \\\n \"$GL_URL/api/v4/projects?membership=true&simple=true\" | \\\n python3 -c \"import sys,json; projects=json.load(sys.stdin); [print(p['name'] + ': ' + p.get('default_branch','main')) for p in projects[:10]]\" 2>&1\n ;;\n *) echo \"Available: !pipeline status\" ;;\nesac","authentication":"privateKey"},"credentials":{"sshPrivateKey":{"id":"SSH_CRED_ID","name":"Server SSH Key"}},"typeVersion":1,"continueOnFail":true},{"id":"format-pipeline","name":"Format Pipeline Response","type":"n8n-nodes-base.code","position":[2272,592],"parameters":{"jsCode":"const config = $('Gateway Config').first().json;\nconst output = ($input.first().json.stdout || '').trim();\nconst body = output || 'No pipeline data available.';\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"},"typeVersion":2},{"id":"handle-help","name":"Handle Help","type":"n8n-nodes-base.code","position":[2064,832],"parameters":{"jsCode":"const config = $('Gateway Config').first().json;\nconst body = 'Claude Gateway Commands:\\n\\n'\n + '!session current — Show current session\\n'\n + '!session list — List all sessions\\n'\n + '!session done — End current session\\n'\n + '!session cancel — Cancel session (no summary)\\n'\n + '!session pause — Pause message delivery\\n'\n + '!session resume — Resume paused session\\n\\n'\n + '!issue status — In-progress issues\\n'\n + '!issue info — Issue details\\n\\n'\n + '!pipeline status — Pipeline overview\\n\\n'\n + '!system status — Server load & memory\\n\\n'\n + 'Prefix routing: PROJ-4: your message';\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"},"typeVersion":2},{"id":"handle-system","name":"Handle System Command","type":"n8n-nodes-base.ssh","position":[2064,976],"parameters":{"command":"LOAD=$(uptime | sed 's/.*load average: //') && MEM=$(free -h | awk '/Mem:/{print $3\"/\"$2}') && PROCS=$(pgrep -c -f claude 2>/dev/null || echo 0) && echo \"Load: $LOAD\" && echo \"Memory: $MEM\" && echo \"Claude processes: $PROCS\"","authentication":"privateKey"},"credentials":{"sshPrivateKey":{"id":"SSH_CRED_ID","name":"Server SSH Key"}},"typeVersion":1,"continueOnFail":true},{"id":"format-system","name":"Format System Response","type":"n8n-nodes-base.code","position":[2272,912],"parameters":{"jsCode":"const config = $('Gateway Config').first().json;\nconst output = ($input.first().json.stdout || '').trim();\nconst body = output || 'No system data.';\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"},"typeVersion":2},{"id":"handle-unknown","name":"Handle Unknown Command","type":"n8n-nodes-base.code","position":[2064,1168],"parameters":{"jsCode":"const config = $('Gateway Config').first().json;\nif ($json.matrixBody) return [{ json: { matrixBody: $json.matrixBody, ...config } }];\nconst body = 'Unknown command: !' + ($json.command || 'unknown') + '\\nType !help to see available commands.';\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"},"typeVersion":2},{"id":"post-cmd-response","name":"Post Command Response","type":"n8n-nodes-base.httpRequest","position":[2496,592],"parameters":{"url":"={{ $json.MATRIX_HOMESERVER }}/_matrix/client/v3/rooms/{{ $json.MATRIX_ROOM_ID }}/send/m.room.message/cmd-{{ Date.now() }}","method":"PUT","options":{},"jsonBody":"={{ $json.matrixBody }}","sendBody":true,"specifyBody":"json","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth"},"credentials":{"httpHeaderAuth":{"id":"MATRIX_CRED_ID","name":"Matrix Bot Token"}},"typeVersion":4.2,"continueOnFail":true},{"id":"session-end-check","name":"Is Session Done?","type":"n8n-nodes-base.if","position":[2720,592],"parameters":{"options":{},"conditions":{"options":{"version":2,"leftValue":"","caseSensitive":true,"typeValidation":"strict"},"combinator":"and","conditions":[{"id":"c70feebe","operator":{"type":"string","operation":"contains"},"leftValue":"={{ $('Handle Session Command').first().json.stdout || '' }}","rightValue":"session_id"}]}},"typeVersion":2},{"id":"cleanup-session","name":"Clean Up & End Session","type":"n8n-nodes-base.ssh","position":[2944,592],"parameters":{"command":"=DB=\"{{ $(\"Gateway Config\").first().json.DB_PATH }}\"\nGW=\"{{ $(\"Gateway Config\").first().json.CONTEXT_DIR }}\"\nPROJECT=\"{{ $(\"Gateway Config\").first().json.CLAUDE_PROJECT_PATH }}\"\nCLAUDE_BIN=\"{{ $(\"Gateway Config\").first().json.CLAUDE_BINARY }}\"\nSESSION_OUT=\"{{ $(\"Handle Session Command\").first().json.stdout || '' }}\"\n\nSESSION_OUT=$(echo \"$SESSION_OUT\" | tr -d '\\n')\n[ -z \"$SESSION_OUT\" ] && echo \"NO_SESSION\" && exit 0\n\nSID=$(echo \"$SESSION_OUT\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('session_id',''))\" 2>/dev/null)\nISSUE=$(echo \"$SESSION_OUT\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('issue_id',''))\" 2>/dev/null)\n[ -z \"$SID\" ] && echo \"NO_SESSION\" && exit 0\n\n# Get summary from Claude\nunset CLAUDECODE\ncd \"$PROJECT\"\ntimeout 60 \"$CLAUDE_BIN\" -r \"$SID\" -p \"Summarize what you worked on in 2-3 sentences\" --output-format json --dangerously-skip-permissions 2>&1\n\n# Archive and clean up\nsqlite3 \"$DB\" \"INSERT INTO session_log (issue_id,issue_title,session_id,started_at,message_count,outcome) SELECT issue_id,issue_title,session_id,started_at,message_count,'done' FROM sessions WHERE issue_id='$ISSUE'; DELETE FROM sessions WHERE issue_id='$ISSUE'; DELETE FROM queue WHERE issue_id='$ISSUE';\"\nrm -f \"$GW/gateway.lock\"\ntouch \"$GW/gateway.cooldown.$ISSUE\"\necho \"SESSION_ENDED:$ISSUE\"","authentication":"privateKey"},"credentials":{"sshPrivateKey":{"id":"SSH_CRED_ID","name":"Server SSH Key"}},"typeVersion":1,"continueOnFail":true},{"id":"post-session-ended","name":"Post Session Ended","type":"n8n-nodes-base.httpRequest","position":[3152,592],"parameters":{"url":"={{ $('Gateway Config').first().json.MATRIX_HOMESERVER }}/_matrix/client/v3/rooms/{{ $('Gateway Config').first().json.MATRIX_ROOM_ID }}/send/m.room.message/end-{{ Date.now() }}","method":"PUT","options":{},"jsonBody":"={{ JSON.stringify({ msgtype: 'm.notice', body: 'Session ended. Summary posted to YouTrack.' }) }}","sendBody":true,"specifyBody":"json","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth"},"credentials":{"httpHeaderAuth":{"id":"MATRIX_CRED_ID","name":"Matrix Bot Token"}},"typeVersion":4.2,"continueOnFail":true},{"id":"cred-sticky","name":"Sticky Note Credentials","type":"n8n-nodes-base.stickyNote","position":[208,800],"parameters":{"color":6,"width":520,"height":508,"content":"### Credentials Setup\n\n**Do NOT store API tokens in workflow nodes.**\nUse n8n's credential system instead:\n\n1. **Matrix Bot Token** — HTTP Header Auth\n `Authorization: Bearer `\n\n2. **SSH Private Key** — SSH credential\n for your Claude Code server\n\n**API tokens for YouTrack & GitLab:**\nSSH command nodes reference `$YT_TOKEN` and\n`$GL_TOKEN` environment variables. Set these\nin `~/.bashrc` on the remote server:\n```\nexport YT_TOKEN=perm-YOUR-TOKEN\nexport GL_TOKEN=glpat-YOUR-TOKEN\n```\n\nThis keeps tokens out of the workflow JSON\nand uses the server's environment instead."},"typeVersion":1}],"active":false,"pinData":{},"settings":{"callerPolicy":"workflowsFromSameOwner","availableInMCP":false,"executionOrder":"v1","saveDataErrorExecution":"all","saveDataSuccessExecution":"none"},"versionId":"929c4f9a-8b21-4f30-97d1-7b2f6fc1fdd4","connections":{"Check Lock":{"main":[[{"node":"Is Locked?","type":"main","index":0}]]},"Is Locked?":{"main":[[{"node":"Post Busy Notice","type":"main","index":0}],[{"node":"Read Session & Acquire Lock","type":"main","index":0}]]},"Handle Help":{"main":[[{"node":"Post Command Response","type":"main","index":0}]]},"Has Messages?":{"main":[[{"node":"Detect Command","type":"main","index":0}]]},"Command Router":{"main":[[{"node":"Check Lock","type":"main","index":0}],[{"node":"Handle Session Command","type":"main","index":0}],[{"node":"Handle Help","type":"main","index":0}],[{"node":"Handle Issue Command","type":"main","index":0}],[{"node":"Handle Pipeline Command","type":"main","index":0}],[{"node":"Handle System Command","type":"main","index":0}],[{"node":"Handle Unknown Command","type":"main","index":0}]]},"Detect Command":{"main":[[{"node":"Command Router","type":"main","index":0}]]},"Get Sync Token":{"main":[[{"node":"Poll Matrix Sync","type":"main","index":0}]]},"Poll Every 30s":{"main":[[{"node":"Get Sync Token","type":"main","index":0}]]},"Extract Messages":{"main":[[{"node":"Has Messages?","type":"main","index":0}]]},"Is Session Done?":{"main":[[{"node":"Clean Up & End Session","type":"main","index":0}]]},"Poll Matrix Sync":{"main":[[{"node":"Extract Messages","type":"main","index":0}]]},"Handle Issue Command":{"main":[[{"node":"Format Issue Response","type":"main","index":0}]]},"Format Issue Response":{"main":[[{"node":"Post Command Response","type":"main","index":0}]]},"Handle System Command":{"main":[[{"node":"Format System Response","type":"main","index":0}]]},"Parse Claude Response":{"main":[[{"node":"Post Response to Matrix","type":"main","index":0}]]},"Post Command Response":{"main":[[{"node":"Is Session Done?","type":"main","index":0}]]},"Resume Claude Session":{"main":[[{"node":"Parse Claude Response","type":"main","index":0}]]},"Clean Up & End Session":{"main":[[{"node":"Post Session Ended","type":"main","index":0}]]},"Format System Response":{"main":[[{"node":"Post Command Response","type":"main","index":0}]]},"Handle Session Command":{"main":[[{"node":"Format Session Response","type":"main","index":0}]]},"Handle Unknown Command":{"main":[[{"node":"Post Command Response","type":"main","index":0}]]},"Format Session Response":{"main":[[{"node":"Post Command Response","type":"main","index":0}]]},"Handle Pipeline Command":{"main":[[{"node":"Format Pipeline Response","type":"main","index":0}]]},"Post Response to Matrix":{"main":[[{"node":"Release Lock","type":"main","index":0}]]},"Format Pipeline Response":{"main":[[{"node":"Post Command Response","type":"main","index":0}]]},"Read Session & Acquire Lock":{"main":[[{"node":"Resume Claude Session","type":"main","index":0}]]}}} \ No newline at end of file