knoxis-helper 1.4.7 → 1.5.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.
@@ -81,10 +81,25 @@ function ask(rl, question) {
81
81
  * Copy agent files to ~/.knoxis/agent/ so they persist across npx cache clears,
82
82
  * VPN changes, and reboots. Returns the stable path.
83
83
  */
84
+ function copyDirRecursive(src, dest) {
85
+ if (!fs.existsSync(src)) return;
86
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
87
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
88
+ const s = path.join(src, entry.name);
89
+ const d = path.join(dest, entry.name);
90
+ if (entry.isDirectory()) copyDirRecursive(s, d);
91
+ else if (entry.isFile()) fs.copyFileSync(s, d);
92
+ }
93
+ }
94
+
84
95
  function installAgentLocally(force) {
85
- const sourceAgent = path.join(__dirname, '..', 'lib', 'knoxis-local-agent.js');
86
- const sourcePairProgram = path.join(__dirname, '..', 'lib', 'knoxis-pair-program.js');
87
- const sourceInteractivePair = path.join(__dirname, '..', 'lib', 'knoxis-interactive-pair.js');
96
+ const libDir = path.join(__dirname, '..', 'lib');
97
+ const sourceAgent = path.join(libDir, 'knoxis-local-agent.js');
98
+ const sourcePairProgram = path.join(libDir, 'knoxis-pair-program.js');
99
+ const sourceInteractivePair = path.join(libDir, 'knoxis-interactive-pair.js');
100
+ const sourceStateScaffold = path.join(libDir, 'state-scaffold.js');
101
+ const sourcePortalSync = path.join(libDir, 'portal-sync.js');
102
+ const sourceTemplatesDir = path.join(libDir, 'templates');
88
103
  const sourcePackage = path.join(__dirname, '..', 'package.json');
89
104
 
90
105
  if (!fs.existsSync(sourceAgent)) {
@@ -125,6 +140,21 @@ function installAgentLocally(force) {
125
140
  console.log(' Installed: knoxis-interactive-pair.js');
126
141
  }
127
142
 
143
+ if (fs.existsSync(sourceStateScaffold)) {
144
+ fs.copyFileSync(sourceStateScaffold, path.join(AGENT_DIR, 'state-scaffold.js'));
145
+ console.log(' Installed: state-scaffold.js');
146
+ }
147
+
148
+ if (fs.existsSync(sourcePortalSync)) {
149
+ fs.copyFileSync(sourcePortalSync, path.join(AGENT_DIR, 'portal-sync.js'));
150
+ console.log(' Installed: portal-sync.js');
151
+ }
152
+
153
+ if (fs.existsSync(sourceTemplatesDir)) {
154
+ copyDirRecursive(sourceTemplatesDir, path.join(AGENT_DIR, 'templates'));
155
+ console.log(' Installed: templates/');
156
+ }
157
+
128
158
  if (fs.existsSync(sourcePackage)) {
129
159
  fs.copyFileSync(sourcePackage, path.join(AGENT_DIR, 'package.json'));
130
160
  }
@@ -193,15 +193,30 @@ function runClaudeTurn(message, isResume) {
193
193
  args.push('--session-id', SESSION_ID);
194
194
  }
195
195
 
196
- // shell: true on Windows so the claude.cmd shim resolves via PATHEXT.
197
- // Required since Node 18.20.2/20.12.2 (CVE-2024-27980) refuses to spawn
198
- // .cmd/.bat files without it.
199
- const proc = spawn('claude', args, {
200
- cwd: process.cwd(),
201
- env: process.env,
202
- stdio: ['pipe', 'pipe', 'pipe'],
203
- shell: isWindows
204
- });
196
+ // Windows: claude is usually claude.cmd (npm shim) or claude.exe.
197
+ // .cmd/.bat shims require going through cmd.exe (Node CVE-2024-27980).
198
+ // We can't just pass `shell: true` with an args array — that triggers
199
+ // DEP0190 in Node 22+ (args get concatenated into the shell command
200
+ // line without escaping). Instead, build the command line ourselves
201
+ // and pass it as a single string. Our args are static flags + a UUID
202
+ // (no whitespace, no quotes) so trivial space-quoting is sufficient.
203
+ let proc;
204
+ if (isWindows) {
205
+ const quote = a => /\s/.test(a) ? `"${a}"` : a;
206
+ const cmdLine = ['claude', ...args].map(quote).join(' ');
207
+ proc = spawn(cmdLine, [], {
208
+ cwd: process.cwd(),
209
+ env: process.env,
210
+ stdio: ['pipe', 'pipe', 'pipe'],
211
+ shell: true
212
+ });
213
+ } else {
214
+ proc = spawn('claude', args, {
215
+ cwd: process.cwd(),
216
+ env: process.env,
217
+ stdio: ['pipe', 'pipe', 'pipe']
218
+ });
219
+ }
205
220
 
206
221
  let stdout = '';
207
222
  let stderr = '';
@@ -580,6 +580,47 @@ function resolveInteractiveScript() {
580
580
  return null;
581
581
  }
582
582
 
583
+ // Resolve the kit-aware pair-program runner (used when caller specifies --mode)
584
+ function resolvePairProgramScript() {
585
+ const candidates = [
586
+ path.join(__dirname, 'knoxis-pair-program.js'),
587
+ path.join(__dirname, '..', 'knoxis-pair-program.js'),
588
+ path.join(os.homedir(), '.knoxis', 'agent', 'knoxis-pair-program.js'),
589
+ path.join(__dirname, '..', '..', 'knoxis-pair-program.js'),
590
+ ];
591
+ for (const candidate of candidates) {
592
+ if (fs.existsSync(candidate)) return candidate;
593
+ }
594
+ return null;
595
+ }
596
+
597
+ const KIT_MODES = new Set(['kickoff', 'resume', 'session-end', 'recovery']);
598
+
599
+ function buildPairProgramCommand({ scriptPath, workspace, mode, archetype, pattern, productSlug, projectSlug, taskPrompt, userId, workspaceId, taskIds }) {
600
+ const q = v => `"${escapeForDoubleQuotedShellArg(v)}"`;
601
+ // Mode/archetype/pattern are constrained to known short tokens — no need to quote.
602
+ // Slugs and paths can contain spaces or special chars — always quote.
603
+ const parts = [`node ${q(scriptPath)}`, `--workspace ${q(workspace)}`];
604
+ if (mode) parts.push(`--mode ${mode}`);
605
+ if (archetype) parts.push(`--archetype ${archetype}`);
606
+ if (pattern) parts.push(`--pattern ${pattern}`);
607
+ if (productSlug) parts.push(`--product-slug ${q(productSlug)}`);
608
+ if (projectSlug) parts.push(`--project-slug ${q(projectSlug)}`);
609
+ if (userId) parts.push(`--user-id ${q(userId)}`);
610
+ if (workspaceId) parts.push(`--workspace-id ${q(workspaceId)}`);
611
+ if (Array.isArray(taskIds)) {
612
+ for (const t of taskIds) {
613
+ if (t) parts.push(`--task-id ${q(t)}`);
614
+ }
615
+ }
616
+ if (taskPrompt && taskPrompt.length) {
617
+ // Use base64 to avoid shell-quoting hazards (newlines, quotes, backticks).
618
+ const b64 = Buffer.from(taskPrompt, 'utf8').toString('base64');
619
+ parts.push(`--prompt-base64 ${b64}`);
620
+ }
621
+ return parts.join(' ');
622
+ }
623
+
583
624
  // Request handler
584
625
  async function handleRequest(req, res) {
585
626
  const parsedUrl = url.parse(req.url, true);
@@ -867,8 +908,22 @@ async function handleRequest(req, res) {
867
908
  const body = await parseBody(req);
868
909
  const { workspace, task, file, provider, headless, sessionId, claudeMdContent } = body;
869
910
  const interactive = body.interactive === true || body.interactive === 'true';
870
-
871
- if (!task) {
911
+ const kitMode = typeof body.mode === 'string' && KIT_MODES.has(body.mode) ? body.mode : null;
912
+ const archetype = typeof body.archetype === 'string' ? body.archetype : null;
913
+ const pattern = typeof body.pattern === 'string' ? body.pattern : null;
914
+ const productSlug = typeof body.productSlug === 'string' ? body.productSlug : null;
915
+ const projectSlug = typeof body.projectSlug === 'string' ? body.projectSlug : null;
916
+ // QIG portal linkage — let session records get tied back to the user / workspace / tasks.
917
+ const portalUserId = typeof body.userId === 'string' ? body.userId : null;
918
+ const portalWorkspaceId = typeof body.workspaceId === 'string' ? body.workspaceId : null;
919
+ const portalTaskIds = Array.isArray(body.taskIds)
920
+ ? body.taskIds.filter(t => typeof t === 'string' && t)
921
+ : (typeof body.taskId === 'string' ? [body.taskId] : []);
922
+
923
+ // Kickoff and session-end modes don't strictly need a task — the kit prompt
924
+ // drives the interaction. For all other paths a task is still required.
925
+ const taskOptionalForMode = kitMode === 'kickoff' || kitMode === 'session-end';
926
+ if (!task && !taskOptionalForMode) {
872
927
  return sendJSON(res, 400, { success: false, error: 'Task description required' }, requestOrigin);
873
928
  }
874
929
 
@@ -895,16 +950,44 @@ async function handleRequest(req, res) {
895
950
  }
896
951
  }
897
952
 
898
- // Write the actual task to a temp file
953
+ // Write the actual task to a temp file (used by single-shot and interactive paths)
899
954
  const promptFile = path.join(os.tmpdir(), `knoxis-task-${sessionId || Date.now()}.txt`);
900
- const promptText = file ? `Working on file: ${file}\n\nTask: ${task}` : task;
901
- fs.writeFileSync(promptFile, promptText, 'utf8');
955
+ const promptText = task ? (file ? `Working on file: ${file}\n\nTask: ${task}` : task) : '';
956
+ if (promptText) fs.writeFileSync(promptFile, promptText, 'utf8');
902
957
 
903
- // Determine the command to run
958
+ // Determine the command to run.
904
959
  let command;
905
960
  let mode = 'single-shot';
906
961
 
907
- if (interactive) {
962
+ // Route through knoxis-pair-program.js whenever it's installed — gives
963
+ // every task auto-scaffold, the standards systemIntro, auto-context, and
964
+ // portal-sync. Kit mode adds --mode; default mode runs the standard
965
+ // 4-step pipeline. Interactive (Groq) mode and the legacy claude-pipe
966
+ // fallback remain available when the runner isn't present.
967
+ const ppScript = resolvePairProgramScript();
968
+ if (kitMode && !ppScript) {
969
+ return sendJSON(res, 500, { success: false, error: 'knoxis-pair-program.js not found — reinstall knoxis-helper.' }, requestOrigin);
970
+ }
971
+
972
+ if (kitMode || (ppScript && !interactive && promptText)) {
973
+ command = buildPairProgramCommand({
974
+ scriptPath: ppScript,
975
+ workspace: workspaceDir,
976
+ mode: kitMode || null,
977
+ archetype,
978
+ pattern,
979
+ productSlug,
980
+ projectSlug,
981
+ taskPrompt: promptText,
982
+ userId: portalUserId,
983
+ workspaceId: portalWorkspaceId,
984
+ taskIds: portalTaskIds
985
+ });
986
+ mode = kitMode
987
+ ? `kit:${kitMode}${archetype ? `/${archetype}` : ''}${pattern ? `/${pattern}` : ''}`
988
+ : 'pair-program:default';
989
+ console.log(`🧰 ${kitMode ? 'Kit' : 'Pair-program (default pipeline)'}: ${mode}`);
990
+ } else if (interactive) {
908
991
  const scriptPath = resolveInteractiveScript();
909
992
  if (scriptPath) {
910
993
  // Interactive mode: multi-turn with Groq pair programmer
@@ -917,17 +1000,19 @@ async function handleRequest(req, res) {
917
1000
  command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
918
1001
  }
919
1002
  } else {
920
- // Standard single-shot mode: pipe task to Claude
1003
+ // Legacy fallback when neither pair-program nor interactive scripts are installed.
921
1004
  command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
922
1005
  }
923
1006
 
924
1007
  if (headless) {
925
- if (interactive && mode === 'interactive') {
926
- // Headless interactive: run the script directly as a process
1008
+ // Anything routed through pair-program.js or knoxis-interactive-pair.js
1009
+ // is a script invocation — run the assembled command verbatim.
1010
+ const isScriptInvocation = mode === 'interactive' || mode === 'pair-program:default' || (kitMode != null);
1011
+ if (isScriptInvocation) {
927
1012
  const result = await runHeadlessProcess({
928
1013
  workspace: workspaceDir,
929
1014
  command,
930
- sessionLabel: sessionId || 'interactive-pair'
1015
+ sessionLabel: sessionId || (kitMode ? `kit-${kitMode}` : (mode === 'interactive' ? 'interactive-pair' : 'pair-program'))
931
1016
  });
932
1017
  return sendJSON(res, result.success ? 200 : 500, { ...result, mode }, requestOrigin);
933
1018
  }
@@ -956,10 +1041,15 @@ async function handleRequest(req, res) {
956
1041
 
957
1042
  return sendJSON(res, 200, {
958
1043
  success: true,
959
- message: interactive ? 'Interactive pair programming session started' : 'Pair programming session started',
1044
+ message: kitMode ? `Pair programming session started (kit mode: ${kitMode})`
1045
+ : interactive ? 'Interactive pair programming session started'
1046
+ : 'Pair programming session started',
960
1047
  mode,
1048
+ kitMode: kitMode || null,
1049
+ archetype: archetype || null,
1050
+ pattern: pattern || null,
961
1051
  workspace: workspaceDir,
962
- task,
1052
+ task: task || null,
963
1053
  file: file || null
964
1054
  }, requestOrigin);
965
1055
  } catch (error) {
@@ -1338,8 +1428,37 @@ function connectRelayWebSocket() {
1338
1428
  }
1339
1429
 
1340
1430
  const interactive = msg.interactive === true;
1341
-
1342
- if (taskPrompt) {
1431
+ const kitMode = typeof msg.mode === 'string' && KIT_MODES.has(msg.mode) ? msg.mode : null;
1432
+ const archetype = typeof msg.archetype === 'string' ? msg.archetype : null;
1433
+ const pattern = typeof msg.pattern === 'string' ? msg.pattern : null;
1434
+ const productSlug = typeof msg.productSlug === 'string' ? msg.productSlug : null;
1435
+ const projectSlug = typeof msg.projectSlug === 'string' ? msg.projectSlug : null;
1436
+ const portalUserId = typeof msg.userId === 'string' ? msg.userId : null;
1437
+ const portalWorkspaceId = typeof msg.workspaceId === 'string' ? msg.workspaceId : null;
1438
+ const portalTaskIds = Array.isArray(msg.taskIds)
1439
+ ? msg.taskIds.filter(t => typeof t === 'string' && t)
1440
+ : (typeof msg.taskId === 'string' ? [msg.taskId] : []);
1441
+ const ppScript = resolvePairProgramScript();
1442
+ const routeViaPairProgram = (kitMode || (ppScript && !interactive && taskPrompt));
1443
+
1444
+ if (routeViaPairProgram && ppScript) {
1445
+ command = buildPairProgramCommand({
1446
+ scriptPath: ppScript,
1447
+ workspace: wsDir,
1448
+ mode: kitMode || null,
1449
+ archetype,
1450
+ pattern,
1451
+ productSlug,
1452
+ projectSlug,
1453
+ taskPrompt: taskPrompt || '',
1454
+ userId: portalUserId,
1455
+ workspaceId: portalWorkspaceId,
1456
+ taskIds: portalTaskIds
1457
+ });
1458
+ console.log(` 🧰 ${kitMode ? `Kit mode: ${kitMode}` : 'Pair-program (default pipeline)'}${archetype ? `/${archetype}` : ''}${pattern ? `/${pattern}` : ''}`);
1459
+ } else if (kitMode) {
1460
+ console.warn(` ⚠️ knoxis-pair-program.js not found — cannot run kit mode`);
1461
+ } else if (taskPrompt) {
1343
1462
  promptFile = path.join(os.tmpdir(), `knoxis-task-${msg.requestId || Date.now()}.txt`);
1344
1463
  fs.writeFileSync(promptFile, taskPrompt, 'utf8');
1345
1464
 
@@ -1406,6 +1525,28 @@ function connectRelayWebSocket() {
1406
1525
  }
1407
1526
  } else if (msg.type === 'connected') {
1408
1527
  console.log(`🤝 Backend acknowledged: ${msg.message}`);
1528
+ } else if (msg.type === 'portal_config') {
1529
+ // Backend is auto-distributing the portal-sync token + URL so devs
1530
+ // don't paste them by hand. Merge into ~/.knoxis/config.json so
1531
+ // portal-sync.js picks them up on the next session save.
1532
+ try {
1533
+ ensureKnoxisDir();
1534
+ const cfgPath = path.join(KNOXIS_DIR, 'config.json');
1535
+ let existing = {};
1536
+ try {
1537
+ if (fs.existsSync(cfgPath)) existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) || {};
1538
+ } catch (_) {}
1539
+ if (typeof msg.portalToken === 'string' && msg.portalToken) existing.portalToken = msg.portalToken;
1540
+ if (typeof msg.portalUrl === 'string' && msg.portalUrl) existing.portalUrl = msg.portalUrl;
1541
+ if (typeof msg.portalSessionsPath === 'string' && msg.portalSessionsPath) {
1542
+ existing.portalSessionsPath = msg.portalSessionsPath;
1543
+ }
1544
+ fs.writeFileSync(cfgPath, JSON.stringify(existing, null, 2));
1545
+ // Don't log the token itself.
1546
+ console.log(`🔐 Portal-sync config received (token len ${(msg.portalToken || '').length})`);
1547
+ } catch (cfgErr) {
1548
+ console.warn(`⚠️ Failed to persist portal_config: ${cfgErr.message}`);
1549
+ }
1409
1550
  }
1410
1551
  } catch (e) {
1411
1552
  console.error('❌ Relay message handling error:', e.message);