Skip to content

Commit 22f4859

Browse files
committed
fix: platform-aware session hooks and auto-memory for Qwen Code
1 parent 6aa1303 commit 22f4859

11 files changed

Lines changed: 230 additions & 178 deletions

cli.bundle.mjs

Lines changed: 68 additions & 68 deletions
Large diffs are not rendered by default.

hooks/posttooluse.mjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,29 @@ import "./ensure-deps.mjs";
1010
* Must be fast (<20ms). No network, no LLM, just SQLite writes.
1111
*/
1212

13-
import { readStdin, parseStdin, getSessionId, getSessionDBPath, getInputProjectDir } from "./session-helpers.mjs";
13+
import { readStdin, parseStdin, getSessionId, getSessionDBPath, getInputProjectDir, detectPlatform } from "./session-helpers.mjs";
1414
import { createSessionLoaders, attributeAndInsertEvents } from "./session-loaders.mjs";
1515
import { dirname, resolve } from "node:path";
1616
import { fileURLToPath } from "node:url";
1717
import { readFileSync, unlinkSync } from "node:fs";
1818
import { tmpdir } from "node:os";
1919

2020
// Resolve absolute path for imports — relative dynamic imports can fail
21-
// when Claude Code invokes hooks from a different working directory.
21+
// when the host IDE invokes hooks from a different working directory.
2222
const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
2323
const { loadSessionDB, loadExtract, loadProjectAttribution } = createSessionLoaders(HOOK_DIR);
24+
const platformOpts = detectPlatform();
2425

2526
try {
2627
const raw = await readStdin();
2728
const input = parseStdin(raw);
28-
const projectDir = getInputProjectDir(input);
29+
const projectDir = getInputProjectDir(input, platformOpts);
2930

3031
const { extractEvents } = await loadExtract();
3132
const { resolveProjectAttributions } = await loadProjectAttribution();
3233
const { SessionDB } = await loadSessionDB();
3334

34-
const dbPath = getSessionDBPath();
35+
const dbPath = getSessionDBPath(platformOpts);
3536
const db = new SessionDB({ dbPath });
3637
const sessionId = getSessionId(input);
3738

hooks/precompact.mjs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import "./ensure-deps.mjs";
99
* snapshot (<2KB XML), and stores it for injection after compact.
1010
*/
1111

12-
import { readStdin, parseStdin, getSessionId, getSessionDBPath, resolveConfigDir } from "./session-helpers.mjs";
12+
import { readStdin, parseStdin, getSessionId, getSessionDBPath, resolveConfigDir, detectPlatform } from "./session-helpers.mjs";
1313
import { createSessionLoaders } from "./session-loaders.mjs";
1414
import { appendFileSync } from "node:fs";
1515
import { join, dirname } from "node:path";
@@ -18,7 +18,8 @@ import { fileURLToPath } from "node:url";
1818
// Resolve absolute path for imports
1919
const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
2020
const { loadSessionDB, loadSnapshot } = createSessionLoaders(HOOK_DIR);
21-
const DEBUG_LOG = join(resolveConfigDir(), "context-mode", "precompact-debug.log");
21+
const platformOpts = detectPlatform();
22+
const DEBUG_LOG = join(resolveConfigDir(platformOpts), "context-mode", "precompact-debug.log");
2223

2324
try {
2425
const raw = await readStdin();
@@ -27,7 +28,7 @@ try {
2728
const { buildResumeSnapshot } = await loadSnapshot();
2829
const { SessionDB } = await loadSessionDB();
2930

30-
const dbPath = getSessionDBPath();
31+
const dbPath = getSessionDBPath(platformOpts);
3132
const db = new SessionDB({ dbPath });
3233
const sessionId = getSessionId(input);
3334

hooks/session-db.bundle.mjs

Lines changed: 19 additions & 13 deletions
Large diffs are not rendered by default.

hooks/session-extract.bundle.mjs

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

hooks/session-helpers.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ const CLAUDE_OPTS = {
5151
sessionIdEnv: "CLAUDE_SESSION_ID",
5252
};
5353

54+
/** Qwen Code platform options. */
55+
export const QWEN_OPTS = {
56+
configDir: ".qwen",
57+
configDirEnv: undefined, // Qwen Code has no config dir env var
58+
projectDirEnv: "QWEN_PROJECT_DIR",
59+
sessionIdEnv: "QWEN_SESSION_ID",
60+
};
61+
5462
/** Gemini CLI platform options. */
5563
export const GEMINI_OPTS = {
5664
configDir: ".gemini",
@@ -99,6 +107,24 @@ export const JETBRAINS_OPTS = {
99107
sessionIdEnv: undefined,
100108
};
101109

110+
/**
111+
* Auto-detect the running platform from environment variables.
112+
* Checks platform-specific env vars in priority order.
113+
* Falls back to CLAUDE_OPTS when no platform is detected.
114+
*
115+
* @returns {object} Platform options { configDir, configDirEnv, projectDirEnv, sessionIdEnv }
116+
*/
117+
export function detectPlatform() {
118+
if (process.env.QWEN_PROJECT_DIR || process.env.QWEN_SESSION_ID) return QWEN_OPTS;
119+
if (process.env.GEMINI_PROJECT_DIR) return GEMINI_OPTS;
120+
if (process.env.VSCODE_CWD) return VSCODE_OPTS;
121+
if (process.env.CURSOR_CWD) return CURSOR_OPTS;
122+
if (process.env.CODEX_HOME) return CODEX_OPTS;
123+
if (process.env.IDEA_INITIAL_DIRECTORY) return JETBRAINS_OPTS;
124+
// Kiro has no unique env var — detected via tool_name prefix in hook stdin
125+
return CLAUDE_OPTS;
126+
}
127+
102128
/**
103129
* Resolve the platform config directory, respecting env var overrides.
104130
* Platforms like Claude Code (CLAUDE_CONFIG_DIR), Gemini CLI (GEMINI_CLI_HOME),

hooks/session-snapshot.bundle.mjs

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
1-
function a(t){return t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;")}var M=10;function h(t,r=4){return[...new Set(t.filter(o=>o.length>0))].slice(0,r).map(o=>o.length>80?o.slice(0,80):o)}function m(t,r){if(r.length===0)return"";let s=r.map(n=>`"${a(n)}"`).join(", ");return`
1+
function a(t){return t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;")}var x=10;function h(t,r=4){return[...new Set(t.filter(o=>o.length>0))].slice(0,r).map(o=>o.length>80?o.slice(0,80):o)}function m(t,r){if(r.length===0)return"";let s=r.map(n=>`"${a(n)}"`).join(", ");return`
22
For full details:
33
${a(t)}(
44
queries: [${s}],
55
source: "session-events"
6-
)`}function A(t,r){if(t.length===0)return"";let s=new Map;for(let l of t){let b=l.data,p=s.get(b);p||(p={ops:new Map},s.set(b,p));let g;l.type==="file_write"?g="write":l.type==="file_read"?g="read":l.type==="file_edit"?g="edit":g=l.type,p.ops.set(g,(p.ops.get(g)??0)+1)}let o=Array.from(s.entries()).slice(-M),u=[],i=[];for(let[l,{ops:b}]of o){let p=Array.from(b.entries()).map(([S,y])=>`${S}\xD7${y}`).join(", "),g=l.split("/").pop()??l;u.push(` ${a(g)} (${a(p)})`),i.push(`${g} ${Array.from(b.keys()).join(" ")}`)}let e=h(i);return[` <files count="${s.size}">`,...u,m(r,e)," </files>"].join(`
7-
`)}function x(t,r){if(t.length===0)return"";let s=[],n=[];for(let i of t)s.push(` ${a(i.data)}`),n.push(i.data);let o=h(n);return[` <errors count="${t.length}">`,...s,m(r,o)," </errors>"].join(`
8-
`)}function D(t,r){if(t.length===0)return"";let s=new Set,n=[],o=[];for(let e of t)s.has(e.data)||(s.add(e.data),n.push(` ${a(e.data)}`),o.push(e.data));if(n.length===0)return"";let u=h(o);return[` <decisions count="${n.length}">`,...n,m(r,u)," </decisions>"].join(`
9-
`)}function F(t,r){if(t.length===0)return"";let s=new Set,n=[],o=[];for(let e of t)s.has(e.data)||(s.add(e.data),e.type==="rule_content"?n.push(` ${a(e.data)}`):n.push(` ${a(e.data)}`),o.push(e.data));if(n.length===0)return"";let u=h(o);return[` <rules count="${n.length}">`,...n,m(r,u)," </rules>"].join(`
10-
`)}function R(t,r){if(t.length===0)return"";let s=[],n=[];for(let i of t)s.push(` ${a(i.data)}`),n.push(i.data);let o=h(n);return[` <git count="${t.length}">`,...s,m(r,o)," </git>"].join(`
11-
`)}function B(t){if(t.length===0)return"";let r=[],s={};for(let e of t)try{let c=JSON.parse(e.data);typeof c.subject=="string"?r.push(c.subject):typeof c.taskId=="string"&&typeof c.status=="string"&&(s[c.taskId]=c.status)}catch{}if(r.length===0)return"";let n=new Set(["completed","deleted","failed"]),o=Object.keys(s).sort((e,c)=>Number(e)-Number(c)),u=[];for(let e=0;e<r.length;e++){let c=o[e],l=c?s[c]??"pending":"pending";n.has(l)||u.push(r[e])}if(u.length===0)return"";let i=[];for(let e of u)i.push(` [pending] ${a(e)}`);return i.join(`
12-
`)}function J(t,r){let s=B(t);if(!s)return"";let n=[];for(let e of t)try{let c=JSON.parse(e.data);typeof c.subject=="string"&&n.push(c.subject)}catch{}let o=h(n);return[` <task_state count="${s.split(`
6+
)`}function D(t,r){if(t.length===0)return"";let s=new Map;for(let l of t){let S=l.data,p=s.get(S);p||(p={ops:new Map},s.set(S,p));let d;l.type==="file_write"?d="write":l.type==="file_read"?d="read":l.type==="file_edit"?d="edit":d=l.type,p.ops.set(d,(p.ops.get(d)??0)+1)}let o=Array.from(s.entries()).slice(-x),c=[],i=[];for(let[l,{ops:S}]of o){let p=Array.from(S.entries()).map(([b,y])=>`${b}\xD7${y}`).join(", "),d=l.split("/").pop()??l;c.push(` ${a(d)} (${a(p)})`),i.push(`${d} ${Array.from(S.keys()).join(" ")}`)}let e=h(i);return[` <files count="${s.size}">`,...c,m(r,e)," </files>"].join(`
7+
`)}function R(t,r){if(t.length===0)return"";let s=[],n=[];for(let i of t)s.push(` ${a(i.data)}`),n.push(i.data);let o=h(n);return[` <errors count="${t.length}">`,...s,m(r,o)," </errors>"].join(`
8+
`)}function F(t,r){if(t.length===0)return"";let s=new Set,n=[],o=[];for(let e of t)s.has(e.data)||(s.add(e.data),n.push(` ${a(e.data)}`),o.push(e.data));if(n.length===0)return"";let c=h(o);return[` <decisions count="${n.length}">`,...n,m(r,c)," </decisions>"].join(`
9+
`)}function B(t,r){if(t.length===0)return"";let s=new Set,n=[],o=[];for(let e of t)s.has(e.data)||(s.add(e.data),e.type==="rule_content"?n.push(` ${a(e.data)}`):n.push(` ${a(e.data)}`),o.push(e.data));if(n.length===0)return"";let c=h(o);return[` <rules count="${n.length}">`,...n,m(r,c)," </rules>"].join(`
10+
`)}function J(t,r){if(t.length===0)return"";let s=[],n=[];for(let i of t)s.push(` ${a(i.data)}`),n.push(i.data);let o=h(n);return[` <git count="${t.length}">`,...s,m(r,o)," </git>"].join(`
11+
`)}function X(t){if(t.length===0)return"";let r=[],s={};for(let e of t)try{let u=JSON.parse(e.data);typeof u.subject=="string"?r.push(u.subject):typeof u.taskId=="string"&&typeof u.status=="string"&&(s[u.taskId]=u.status)}catch{}if(r.length===0)return"";let n=new Set(["completed","deleted","failed"]),o=Object.keys(s).sort((e,u)=>Number(e)-Number(u)),c=[];for(let e=0;e<r.length;e++){let u=o[e],l=u?s[u]??"pending":"pending";n.has(l)||c.push(r[e])}if(c.length===0)return"";let i=[];for(let e of c)i.push(` [pending] ${a(e)}`);return i.join(`
12+
`)}function z(t,r){let s=X(t);if(!s)return"";let n=[];for(let e of t)try{let u=JSON.parse(e.data);typeof u.subject=="string"&&n.push(u.subject)}catch{}let o=h(n);return[` <task_state count="${s.split(`
1313
`).length}">`,s,m(r,o)," </task_state>"].join(`
14-
`)}function X(t,r,s){if(t.length===0&&r.length===0)return"";let n=[],o=[];if(t.length>0){let e=t[t.length-1];n.push(` cwd: ${a(e.data)}`),o.push("working directory")}for(let e of r)n.push(` ${a(e.data)}`),o.push(e.data);let u=h(o);return[" <environment>",...n,m(s,u)," </environment>"].join(`
15-
`)}function z(t,r){if(t.length===0)return"";let s=[],n=[];for(let i of t){let e=i.type==="subagent_completed"?"completed":i.type==="subagent_launched"?"launched":"unknown";s.push(` [${e}] ${a(i.data)}`),n.push(`subagent ${i.data}`)}let o=h(n);return[` <subagents count="${t.length}">`,...s,m(r,o)," </subagents>"].join(`
16-
`)}function G(t,r){if(t.length===0)return"";let s=new Map;for(let e of t){let c=e.data.split(":")[0].trim();s.set(c,(s.get(c)??0)+1)}let n=[],o=[];for(let[e,c]of s)n.push(` ${a(e)} (${c}\xD7)`),o.push(`skill ${e} invocation`);let u=h(o);return[` <skills count="${t.length}">`,...n,m(r,u)," </skills>"].join(`
17-
`)}function P(t){if(t.length===0)return"";let r=t[t.length-1];return` <intent mode="${a(r.data)}"/>`}function V(t,r){let s=r?.compactCount??1,n=r?.searchTool??"ctx_search",o=new Date().toISOString(),u=[],i=[],e=[],c=[],l=[],b=[],p=[],g=[],S=[],y=[],k=[];for(let d of t)switch(d.category){case"file":u.push(d);break;case"task":i.push(d);break;case"rule":e.push(d);break;case"decision":c.push(d);break;case"cwd":l.push(d);break;case"error":b.push(d);break;case"env":p.push(d);break;case"git":g.push(d);break;case"subagent":S.push(d);break;case"intent":y.push(d);break;case"skill":k.push(d);break}let f=[];f.push(` <how_to_search>
14+
`)}function G(t,r,s){if(t.length===0&&r.length===0)return"";let n=[],o=[];if(t.length>0){let e=t[t.length-1];n.push(` cwd: ${a(e.data)}`),o.push("working directory")}for(let e of r)n.push(` ${a(e.data)}`),o.push(e.data);let c=h(o);return[" <environment>",...n,m(s,c)," </environment>"].join(`
15+
`)}function P(t,r){if(t.length===0)return"";let s=[],n=[];for(let i of t){let e=i.type==="subagent_completed"?"completed":i.type==="subagent_launched"?"launched":"unknown";s.push(` [${e}] ${a(i.data)}`),n.push(`subagent ${i.data}`)}let o=h(n);return[` <subagents count="${t.length}">`,...s,m(r,o)," </subagents>"].join(`
16+
`)}function Q(t,r){if(t.length===0)return"";let s=new Map;for(let e of t){let u=e.data.split(":")[0].trim();s.set(u,(s.get(u)??0)+1)}let n=[],o=[];for(let[e,u]of s)n.push(` ${a(e)} (${u}\xD7)`),o.push(`skill ${e} invocation`);let c=h(o);return[` <skills count="${t.length}">`,...n,m(r,c)," </skills>"].join(`
17+
`)}function U(t,r){if(t.length===0)return"";let s=new Set,n=[],o=[];for(let e of t)s.has(e.data)||(s.add(e.data),n.push(` ${a(e.data)}`),o.push(e.data));if(n.length===0)return"";let c=h(o);return[` <roles count="${n.length}">`,...n,m(r,c)," </roles>"].join(`
18+
`)}function V(t){if(t.length===0)return"";let r=t[t.length-1];return` <intent mode="${a(r.data)}"/>`}function W(t,r){let s=r?.compactCount??1,n=r?.searchTool??"ctx_search",o=new Date().toISOString(),c=[],i=[],e=[],u=[],l=[],S=[],p=[],d=[],b=[],y=[],$=[],k=[];for(let f of t)switch(f.category){case"file":c.push(f);break;case"task":i.push(f);break;case"rule":e.push(f);break;case"decision":u.push(f);break;case"cwd":l.push(f);break;case"error":S.push(f);break;case"env":p.push(f);break;case"git":d.push(f);break;case"subagent":b.push(f);break;case"intent":y.push(f);break;case"skill":$.push(f);break;case"role":k.push(f);break}let g=[];g.push(` <how_to_search>
1819
Each section below contains a summary of prior work.
1920
For FULL DETAILS, run the exact tool call shown under each section.
2021
Do NOT ask the user to re-explain prior work. Search first.
2122
Do NOT invent your own queries \u2014 use the ones provided.
22-
</how_to_search>`);let $=A(u,n);$&&f.push($);let v=x(b,n);v&&f.push(v);let w=D(c,n);w&&f.push(w);let E=F(e,n);E&&f.push(E);let q=R(g,n);q&&f.push(q);let L=J(i,n);L&&f.push(L);let _=X(l,p,n);_&&f.push(_);let j=z(S,n);j&&f.push(j);let T=G(k,n);T&&f.push(T);let C=P(y);C&&f.push(C);let O=`<session_resume events="${t.length}" compact_count="${s}" generated_at="${o}">`,I="</session_resume>",N=f.join(`
23+
</how_to_search>`);let v=D(c,n);v&&g.push(v);let w=R(S,n);w&&g.push(w);let E=F(u,n);E&&g.push(E);let q=B(e,n);q&&g.push(q);let L=J(d,n);L&&g.push(L);let j=z(i,n);j&&g.push(j);let _=G(l,p,n);_&&g.push(_);let T=P(b,n);T&&g.push(T);let C=Q($,n);C&&g.push(C);let O=U(k,n);O&&g.push(O);let I=V(y);I&&g.push(I);let N=`<session_resume events="${t.length}" compact_count="${s}" generated_at="${o}">`,M="</session_resume>",A=g.join(`
2324
24-
`);return N?`${O}
25+
`);return A?`${N}
2526
26-
${N}
27+
${A}
2728
28-
${I}`:`${O}
29-
${I}`}export{V as buildResumeSnapshot,B as renderTaskState};
29+
${M}`:`${N}
30+
${M}`}export{W as buildResumeSnapshot,X as renderTaskState};

hooks/sessionstart.mjs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ import { createRoutingBlock } from "./routing-block.mjs";
1919
import { createToolNamer } from "./core/tool-naming.mjs";
2020
import { buildAutoInjection } from "./auto-injection.mjs";
2121

22-
const toolNamer = createToolNamer("claude-code");
22+
// Auto-detect platform for correct env vars and config paths
23+
const platformOpts = detectPlatform();
24+
const platformId = platformOpts.configDir === ".qwen" ? "qwen-code" : "claude-code";
25+
const toolNamer = createToolNamer(platformId);
2326
const ROUTING_BLOCK = createRoutingBlock(toolNamer);
24-
import { readStdin, parseStdin, getSessionId, getSessionDBPath, getSessionEventsPath, getCleanupFlagPath, resolveConfigDir } from "./session-helpers.mjs";
27+
import { readStdin, parseStdin, getSessionId, getSessionDBPath, getSessionEventsPath, getCleanupFlagPath, resolveConfigDir, detectPlatform } from "./session-helpers.mjs";
2528
import { writeSessionEventsFile, buildSessionDirective, getSessionEvents, getLatestSessionEvents } from "./session-directive.mjs";
2629
import { createSessionLoaders } from "./session-loaders.mjs";
2730
import { join, dirname } from "node:path";
@@ -102,18 +105,21 @@ try {
102105
db.cleanupOldSessions(7);
103106
db.db.exec(`DELETE FROM session_events WHERE session_id NOT IN (SELECT session_id FROM session_meta)`);
104107

105-
// Proactively capture CLAUDE.md files — Claude Code loads them as system
108+
// Proactively capture CLAUDE.md / QWEN.md files — the host IDE loads them as system
106109
// context at startup, invisible to PostToolUse hooks. We read them from
107110
// disk so they survive compact/resume via the session events pipeline.
108111
const sessionId = getSessionId(input);
109-
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
112+
const projectDir = process.env[platformOpts.projectDirEnv] || process.cwd();
110113
db.ensureSession(sessionId, projectDir);
111-
const claudeMdPaths = [
112-
join(resolveConfigDir(), "CLAUDE.md"),
113-
join(projectDir, "CLAUDE.md"),
114-
join(projectDir, ".claude", "CLAUDE.md"),
114+
const memoryFileNames = platformOpts.configDir === ".qwen"
115+
? ["QWEN.md"]
116+
: ["CLAUDE.md"];
117+
const memoryMdPaths = [
118+
join(resolveConfigDir(platformOpts), memoryFileNames[0]),
119+
join(projectDir, memoryFileNames[0]),
120+
join(projectDir, platformOpts.configDir, memoryFileNames[0]),
115121
];
116-
for (const p of claudeMdPaths) {
122+
for (const p of memoryMdPaths) {
117123
try {
118124
const content = readFileSync(p, "utf-8");
119125
if (content.trim()) {
@@ -128,7 +134,10 @@ try {
128134
// Age-gated lazy cleanup of old plugin cache version dirs (#181).
129135
// Only delete dirs older than 1 hour to avoid breaking active sessions.
130136
try {
131-
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
137+
const pluginRootEnv = platformOpts.configDir === ".qwen"
138+
? "QWEN_PLUGIN_ROOT"
139+
: "CLAUDE_PLUGIN_ROOT";
140+
const pluginRoot = process.env[pluginRootEnv];
132141
if (pluginRoot) {
133142
const cacheParentMatch = pluginRoot.match(/^(.*[\\/]plugins[\\/]cache[\\/][^\\/]+[\\/][^\\/]+[\\/])/);
134143
if (cacheParentMatch) {

0 commit comments

Comments
 (0)