knoxis-helper 1.4.8 → 1.5.2

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,26 @@ 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 sourceSessionRecorder = path.join(libDir, 'session-recorder.js');
103
+ const sourceTemplatesDir = path.join(libDir, 'templates');
88
104
  const sourcePackage = path.join(__dirname, '..', 'package.json');
89
105
 
90
106
  if (!fs.existsSync(sourceAgent)) {
@@ -125,6 +141,26 @@ function installAgentLocally(force) {
125
141
  console.log(' Installed: knoxis-interactive-pair.js');
126
142
  }
127
143
 
144
+ if (fs.existsSync(sourceStateScaffold)) {
145
+ fs.copyFileSync(sourceStateScaffold, path.join(AGENT_DIR, 'state-scaffold.js'));
146
+ console.log(' Installed: state-scaffold.js');
147
+ }
148
+
149
+ if (fs.existsSync(sourcePortalSync)) {
150
+ fs.copyFileSync(sourcePortalSync, path.join(AGENT_DIR, 'portal-sync.js'));
151
+ console.log(' Installed: portal-sync.js');
152
+ }
153
+
154
+ if (fs.existsSync(sourceSessionRecorder)) {
155
+ fs.copyFileSync(sourceSessionRecorder, path.join(AGENT_DIR, 'session-recorder.js'));
156
+ console.log(' Installed: session-recorder.js');
157
+ }
158
+
159
+ if (fs.existsSync(sourceTemplatesDir)) {
160
+ copyDirRecursive(sourceTemplatesDir, path.join(AGENT_DIR, 'templates'));
161
+ console.log(' Installed: templates/');
162
+ }
163
+
128
164
  if (fs.existsSync(sourcePackage)) {
129
165
  fs.copyFileSync(sourcePackage, path.join(AGENT_DIR, 'package.json'));
130
166
  }
@@ -33,6 +33,9 @@ const https = require('https');
33
33
  const fs = require('fs');
34
34
  const path = require('path');
35
35
  const os = require('os');
36
+ const { SessionRecorder } = require('./session-recorder');
37
+ const { scaffoldStateLayout } = require('./state-scaffold');
38
+ const { syncSessionToPortal } = require('./portal-sync');
36
39
 
37
40
  // === CONFIG ===
38
41
  const CONFIG_PATH = path.join(os.homedir(), '.knoxis', 'config.json');
@@ -269,6 +272,42 @@ async function runSingleShot(task) {
269
272
  return result.code || 0;
270
273
  }
271
274
 
275
+ // === IDENTITY (passed by knoxis-local-agent via env vars) ===
276
+ function readIdentityFromEnv() {
277
+ const taskIds = (process.env.KNOXIS_TASK_IDS || '')
278
+ .split(',')
279
+ .map(s => s.trim())
280
+ .filter(Boolean);
281
+ return {
282
+ workspace: process.env.KNOXIS_WORKSPACE_PATH || process.cwd(),
283
+ userId: process.env.KNOXIS_USER_ID || (config && config.userId) || null,
284
+ workspaceId: process.env.KNOXIS_WORKSPACE_ID || null,
285
+ taskIds,
286
+ productSlug: process.env.KNOXIS_PRODUCT_SLUG || null,
287
+ projectSlug: process.env.KNOXIS_PROJECT_SLUG || null,
288
+ engineerId: process.env.KNOXIS_USER_ID || (config && config.userId) || null
289
+ };
290
+ }
291
+
292
+ // === RECORDER + PORTAL FINALIZATION ===
293
+ // Saves the session JSON locally and POSTs to the portal stub. Called from
294
+ // every exit path so partial / fallback sessions still surface in the UI.
295
+ async function finalizeSession(recorder) {
296
+ if (!recorder) return;
297
+ try {
298
+ const filepath = await recorder.saveAsync();
299
+ console.log(' Session JSON: ' + filepath);
300
+ } catch (e) {
301
+ console.warn(' Could not save session JSON: ' + e.message);
302
+ }
303
+ try {
304
+ const record = recorder.buildFinalRecord();
305
+ await syncSessionToPortal(record);
306
+ } catch (e) {
307
+ console.warn(' Portal-sync errored: ' + e.message);
308
+ }
309
+ }
310
+
272
311
  // === MAIN ===
273
312
  async function main() {
274
313
  const task = loadTask();
@@ -282,15 +321,40 @@ async function main() {
282
321
 
283
322
  initSessionLog();
284
323
 
324
+ // Auto-scaffold + recorder. Idempotent — pre-existing files preserved.
325
+ const identity = readIdentityFromEnv();
326
+ let scaffoldResult = null;
327
+ try {
328
+ scaffoldResult = scaffoldStateLayout(identity.workspace);
329
+ } catch (e) {
330
+ console.warn(' Scaffold failed: ' + e.message);
331
+ }
332
+
333
+ const recorder = new SessionRecorder(task, identity.workspace, 'Claude Code + Knoxis (Groq)', {
334
+ mode: null, // interactive flow isn't a kit mode
335
+ archetype: null,
336
+ productSlug: identity.productSlug,
337
+ projectSlug: identity.projectSlug,
338
+ engineerId: identity.engineerId,
339
+ userId: identity.userId,
340
+ workspaceId: identity.workspaceId,
341
+ taskIds: identity.taskIds
342
+ });
343
+
285
344
  console.log('');
286
345
  console.log('╔══════════════════════════════════════════════════════════════╗');
287
346
  console.log('║ KNOXIS INTERACTIVE PAIR PROGRAMMING ║');
288
347
  console.log('╚══════════════════════════════════════════════════════════════╝');
289
348
  console.log('');
290
- console.log(' Task: ' + task.substring(0, 100) + (task.length > 100 ? '...' : ''));
291
- console.log(' Session: ' + SESSION_ID);
292
- console.log(' Pair: ' + (hasGroq ? 'Groq (' + GROQ_MODEL + ')' : 'Disabled (no GROQ_API_KEY)'));
293
- console.log(' Timeout: ' + (MAX_PHASE_MS / 60000).toFixed(0) + ' min per phase');
349
+ console.log(' Task: ' + task.substring(0, 100) + (task.length > 100 ? '...' : ''));
350
+ console.log(' Session: ' + SESSION_ID);
351
+ console.log(' Recorded: ' + recorder.sessionId);
352
+ console.log(' Workspace: ' + identity.workspace);
353
+ console.log(' Pair: ' + (hasGroq ? 'Groq (' + GROQ_MODEL + ')' : 'Disabled (no GROQ_API_KEY)'));
354
+ console.log(' Timeout: ' + (MAX_PHASE_MS / 60000).toFixed(0) + ' min per phase');
355
+ if (scaffoldResult && (scaffoldResult.dirs.length || scaffoldResult.files.length)) {
356
+ console.log(' Scaffolded: ' + (scaffoldResult.dirs.length + scaffoldResult.files.length) + ' new entries (CODING_RULES + docs/state/)');
357
+ }
294
358
  console.log('');
295
359
 
296
360
  appendLog('# Knoxis Interactive Pair Programming Session');
@@ -301,7 +365,11 @@ async function main() {
301
365
 
302
366
  // If no Groq, fall back to enhanced single-shot
303
367
  if (!hasGroq) {
368
+ const idx = recorder.startStep('single-shot', 'Claude Code', task);
369
+ recorder.setStepPrompt(idx, task);
304
370
  const code = await runSingleShot(task);
371
+ recorder.completeStep(idx, '(single-shot mode; output streamed live)', code !== 0 ? `exit ${code}` : null);
372
+ await finalizeSession(recorder);
305
373
  process.exit(code);
306
374
  }
307
375
 
@@ -352,8 +420,11 @@ async function main() {
352
420
  'Share your plan and then STOP. Do not implement yet. I will review it first.',
353
421
  ].join('\n');
354
422
 
423
+ const phase1Idx = recorder.startStep('understand-plan', 'Claude Code', planPrompt);
424
+ recorder.setStepPrompt(phase1Idx, planPrompt);
355
425
  const phase1 = await runClaudeTurn(planPrompt, false);
356
426
  appendLog(phase1.stdout + '\n');
427
+ recorder.completeStep(phase1Idx, phase1.stdout, phase1.code !== 0 && !phase1.stdout ? `exit ${phase1.code}: ${phase1.stderr || '(empty)'}` : null);
357
428
 
358
429
  if (phase1.code !== 0 && !phase1.stdout) {
359
430
  console.log('');
@@ -362,9 +433,13 @@ async function main() {
362
433
  console.log(' stderr: ' + phase1.stderr.split('\n').slice(0, 5).join('\n '));
363
434
  }
364
435
  appendLog('Phase 1 failed (exit ' + phase1.code + '). stderr: ' + (phase1.stderr || '(empty)') + '\n');
436
+ const fallbackIdx = recorder.startStep('single-shot-fallback', 'Claude Code', task);
437
+ recorder.setStepPrompt(fallbackIdx, task);
365
438
  const code = await runSingleShot(task);
439
+ recorder.completeStep(fallbackIdx, '(single-shot fallback; output streamed live)', code !== 0 ? `exit ${code}` : null);
366
440
  const logFile = saveSessionLog();
367
441
  if (logFile) console.log(' Log: ' + logFile);
442
+ await finalizeSession(recorder);
368
443
  process.exit(code);
369
444
  }
370
445
 
@@ -377,12 +452,13 @@ async function main() {
377
452
  console.log(' KNOXIS: Reviewing plan...');
378
453
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
379
454
 
380
- const planReview = await callGroq(
381
- groqSystem,
382
- 'Claude produced the following plan:\n\n'
383
- + phase1.stdout.substring(0, 8000)
384
- + '\n\nReview this plan. Answer any questions Claude asked. Approve or suggest specific changes. Then tell Claude to proceed with implementation.'
385
- );
455
+ const planReviewPrompt = 'Claude produced the following plan:\n\n'
456
+ + phase1.stdout.substring(0, 8000)
457
+ + '\n\nReview this plan. Answer any questions Claude asked. Approve or suggest specific changes. Then tell Claude to proceed with implementation.';
458
+ const planReviewIdx = recorder.startStep('knoxis-plan-review', 'Knoxis (Groq)', planReviewPrompt);
459
+ recorder.setStepPrompt(planReviewIdx, planReviewPrompt);
460
+ const planReview = await callGroq(groqSystem, planReviewPrompt);
461
+ recorder.completeStep(planReviewIdx, planReview, null);
386
462
 
387
463
  console.log('');
388
464
  console.log(' Knoxis: ' + planReview);
@@ -399,8 +475,11 @@ async function main() {
399
475
  console.log('');
400
476
  appendLog('## Phase 2: Implementation\n');
401
477
 
478
+ const phase2Idx = recorder.startStep('implement', 'Claude Code', planReview);
479
+ recorder.setStepPrompt(phase2Idx, planReview);
402
480
  const phase2 = await runClaudeTurn(planReview, true);
403
481
  appendLog(phase2.stdout.substring(0, 10000) + '\n');
482
+ recorder.completeStep(phase2Idx, phase2.stdout, phase2.code !== 0 ? `exit ${phase2.code}: ${(phase2.stderr || '').slice(0, 200)}` : null);
404
483
 
405
484
  // If resume failed (session not found), try context accumulation fallback
406
485
  if (phase2.code !== 0 && phase2.stderr && phase2.stderr.includes('session')) {
@@ -418,9 +497,11 @@ async function main() {
418
497
  'Now implement the solution. Follow existing patterns in the codebase.',
419
498
  ].join('\n');
420
499
 
500
+ const phase2bIdx = recorder.startStep('implement-fallback', 'Claude Code', fallbackPrompt);
501
+ recorder.setStepPrompt(phase2bIdx, fallbackPrompt);
421
502
  const phase2b = await runClaudeTurn(fallbackPrompt, false);
422
- // Use new session for subsequent turns
423
503
  appendLog(phase2b.stdout.substring(0, 10000) + '\n');
504
+ recorder.completeStep(phase2bIdx, phase2b.stdout, phase2b.code !== 0 ? `exit ${phase2b.code}` : null);
424
505
 
425
506
  // Skip to verification with this result
426
507
  const verifyPrompt = 'Verify the changes compile/build correctly. Run the most relevant test or build command. Give a brief summary of what was done.';
@@ -430,8 +511,11 @@ async function main() {
430
511
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
431
512
  console.log('');
432
513
 
514
+ const phase3bIdx = recorder.startStep('verify', 'Claude Code', verifyPrompt);
515
+ recorder.setStepPrompt(phase3bIdx, verifyPrompt);
433
516
  const phase3b = await runClaudeTurn(verifyPrompt, true);
434
517
  appendLog('## Phase 3: Verification\n' + phase3b.stdout.substring(0, 5000) + '\n');
518
+ recorder.completeStep(phase3bIdx, phase3b.stdout, phase3b.code !== 0 ? `exit ${phase3b.code}` : null);
435
519
 
436
520
  console.log('');
437
521
  console.log('╔══════════════════════════════════════════════════════════════╗');
@@ -441,6 +525,7 @@ async function main() {
441
525
  if (logFile) console.log(' Log: ' + logFile);
442
526
  console.log(' Resume: claude --resume ' + SESSION_ID);
443
527
  console.log('');
528
+ await finalizeSession(recorder);
444
529
  process.exit(phase3b.code || 0);
445
530
  }
446
531
 
@@ -453,12 +538,13 @@ async function main() {
453
538
  console.log(' KNOXIS: Reviewing implementation...');
454
539
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
455
540
 
456
- const implReview = await callGroq(
457
- groqSystem,
458
- 'Claude implemented the following:\n\n'
459
- + phase2.stdout.substring(0, 8000)
460
- + '\n\nReview the implementation. If there are issues, describe specifically what needs fixing. If it looks correct, tell Claude to verify the build/tests and summarize what was done.'
461
- );
541
+ const implReviewPrompt = 'Claude implemented the following:\n\n'
542
+ + phase2.stdout.substring(0, 8000)
543
+ + '\n\nReview the implementation. If there are issues, describe specifically what needs fixing. If it looks correct, tell Claude to verify the build/tests and summarize what was done.';
544
+ const implReviewIdx = recorder.startStep('knoxis-impl-review', 'Knoxis (Groq)', implReviewPrompt);
545
+ recorder.setStepPrompt(implReviewIdx, implReviewPrompt);
546
+ const implReview = await callGroq(groqSystem, implReviewPrompt);
547
+ recorder.completeStep(implReviewIdx, implReview, null);
462
548
 
463
549
  console.log('');
464
550
  console.log(' Knoxis: ' + implReview);
@@ -475,12 +561,13 @@ async function main() {
475
561
  console.log('');
476
562
  appendLog('## Phase 3: Verification\n');
477
563
 
478
- const phase3 = await runClaudeTurn(
479
- implReview
480
- + '\n\nAfter addressing any feedback above, verify the changes compile/build correctly. Run the most relevant test or build command. Give a brief summary of everything that was done.',
481
- true
482
- );
564
+ const phase3Prompt = implReview
565
+ + '\n\nAfter addressing any feedback above, verify the changes compile/build correctly. Run the most relevant test or build command. Give a brief summary of everything that was done.';
566
+ const phase3Idx = recorder.startStep('verify', 'Claude Code', phase3Prompt);
567
+ recorder.setStepPrompt(phase3Idx, phase3Prompt);
568
+ const phase3 = await runClaudeTurn(phase3Prompt, true);
483
569
  appendLog(phase3.stdout.substring(0, 5000) + '\n');
570
+ recorder.completeStep(phase3Idx, phase3.stdout, phase3.code !== 0 ? `exit ${phase3.code}` : null);
484
571
 
485
572
 
486
573
  // ═══════════════════════════════════════════
@@ -497,6 +584,7 @@ async function main() {
497
584
  if (logFile) console.log(' Log: ' + logFile);
498
585
  console.log('');
499
586
 
587
+ await finalizeSession(recorder);
500
588
  process.exit(phase3.code || 0);
501
589
  }
502
590
 
@@ -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,20 +950,56 @@ 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
- // Interactive mode: multi-turn with Groq pair programmer
911
- command = buildEnvCommand({ KNOXIS_TASK_FILE: promptFile }, `node "${scriptPath}"`);
993
+ // Interactive mode: multi-turn with Groq pair programmer.
994
+ // Pass identity env vars so the script can build a schema-aligned
995
+ // session record and POST it via portal-sync.
996
+ const interactiveEnv = { KNOXIS_TASK_FILE: promptFile, KNOXIS_WORKSPACE_PATH: workspaceDir };
997
+ if (portalUserId) interactiveEnv.KNOXIS_USER_ID = portalUserId;
998
+ if (portalWorkspaceId) interactiveEnv.KNOXIS_WORKSPACE_ID = portalWorkspaceId;
999
+ if (portalTaskIds && portalTaskIds.length) interactiveEnv.KNOXIS_TASK_IDS = portalTaskIds.join(',');
1000
+ if (productSlug) interactiveEnv.KNOXIS_PRODUCT_SLUG = productSlug;
1001
+ if (projectSlug) interactiveEnv.KNOXIS_PROJECT_SLUG = projectSlug;
1002
+ command = buildEnvCommand(interactiveEnv, `node "${scriptPath}"`);
912
1003
  mode = 'interactive';
913
1004
  console.log(`🤝 Interactive mode: ${scriptPath}`);
914
1005
  } else {
@@ -917,17 +1008,19 @@ async function handleRequest(req, res) {
917
1008
  command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
918
1009
  }
919
1010
  } else {
920
- // Standard single-shot mode: pipe task to Claude
1011
+ // Legacy fallback when neither pair-program nor interactive scripts are installed.
921
1012
  command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
922
1013
  }
923
1014
 
924
1015
  if (headless) {
925
- if (interactive && mode === 'interactive') {
926
- // Headless interactive: run the script directly as a process
1016
+ // Anything routed through pair-program.js or knoxis-interactive-pair.js
1017
+ // is a script invocation — run the assembled command verbatim.
1018
+ const isScriptInvocation = mode === 'interactive' || mode === 'pair-program:default' || (kitMode != null);
1019
+ if (isScriptInvocation) {
927
1020
  const result = await runHeadlessProcess({
928
1021
  workspace: workspaceDir,
929
1022
  command,
930
- sessionLabel: sessionId || 'interactive-pair'
1023
+ sessionLabel: sessionId || (kitMode ? `kit-${kitMode}` : (mode === 'interactive' ? 'interactive-pair' : 'pair-program'))
931
1024
  });
932
1025
  return sendJSON(res, result.success ? 200 : 500, { ...result, mode }, requestOrigin);
933
1026
  }
@@ -956,10 +1049,15 @@ async function handleRequest(req, res) {
956
1049
 
957
1050
  return sendJSON(res, 200, {
958
1051
  success: true,
959
- message: interactive ? 'Interactive pair programming session started' : 'Pair programming session started',
1052
+ message: kitMode ? `Pair programming session started (kit mode: ${kitMode})`
1053
+ : interactive ? 'Interactive pair programming session started'
1054
+ : 'Pair programming session started',
960
1055
  mode,
1056
+ kitMode: kitMode || null,
1057
+ archetype: archetype || null,
1058
+ pattern: pattern || null,
961
1059
  workspace: workspaceDir,
962
- task,
1060
+ task: task || null,
963
1061
  file: file || null
964
1062
  }, requestOrigin);
965
1063
  } catch (error) {
@@ -1338,16 +1436,53 @@ function connectRelayWebSocket() {
1338
1436
  }
1339
1437
 
1340
1438
  const interactive = msg.interactive === true;
1341
-
1342
- if (taskPrompt) {
1439
+ const kitMode = typeof msg.mode === 'string' && KIT_MODES.has(msg.mode) ? msg.mode : null;
1440
+ const archetype = typeof msg.archetype === 'string' ? msg.archetype : null;
1441
+ const pattern = typeof msg.pattern === 'string' ? msg.pattern : null;
1442
+ const productSlug = typeof msg.productSlug === 'string' ? msg.productSlug : null;
1443
+ const projectSlug = typeof msg.projectSlug === 'string' ? msg.projectSlug : null;
1444
+ const portalUserId = typeof msg.userId === 'string' ? msg.userId : null;
1445
+ const portalWorkspaceId = typeof msg.workspaceId === 'string' ? msg.workspaceId : null;
1446
+ const portalTaskIds = Array.isArray(msg.taskIds)
1447
+ ? msg.taskIds.filter(t => typeof t === 'string' && t)
1448
+ : (typeof msg.taskId === 'string' ? [msg.taskId] : []);
1449
+ const ppScript = resolvePairProgramScript();
1450
+ const routeViaPairProgram = (kitMode || (ppScript && !interactive && taskPrompt));
1451
+
1452
+ if (routeViaPairProgram && ppScript) {
1453
+ command = buildPairProgramCommand({
1454
+ scriptPath: ppScript,
1455
+ workspace: wsDir,
1456
+ mode: kitMode || null,
1457
+ archetype,
1458
+ pattern,
1459
+ productSlug,
1460
+ projectSlug,
1461
+ taskPrompt: taskPrompt || '',
1462
+ userId: portalUserId,
1463
+ workspaceId: portalWorkspaceId,
1464
+ taskIds: portalTaskIds
1465
+ });
1466
+ console.log(` 🧰 ${kitMode ? `Kit mode: ${kitMode}` : 'Pair-program (default pipeline)'}${archetype ? `/${archetype}` : ''}${pattern ? `/${pattern}` : ''}`);
1467
+ } else if (kitMode) {
1468
+ console.warn(` ⚠️ knoxis-pair-program.js not found — cannot run kit mode`);
1469
+ } else if (taskPrompt) {
1343
1470
  promptFile = path.join(os.tmpdir(), `knoxis-task-${msg.requestId || Date.now()}.txt`);
1344
1471
  fs.writeFileSync(promptFile, taskPrompt, 'utf8');
1345
1472
 
1346
1473
  if (interactive) {
1347
- // Interactive mode: use multi-turn pair programming script
1474
+ // Interactive mode: use multi-turn pair programming script.
1475
+ // Pass identity env vars so the script can build a schema-aligned
1476
+ // session record and POST it via portal-sync.
1348
1477
  const scriptPath = resolveInteractiveScript();
1349
1478
  if (scriptPath) {
1350
- command = buildEnvCommand({ KNOXIS_TASK_FILE: promptFile }, `node "${scriptPath}"`);
1479
+ const interactiveEnv = { KNOXIS_TASK_FILE: promptFile, KNOXIS_WORKSPACE_PATH: wsDir };
1480
+ if (portalUserId) interactiveEnv.KNOXIS_USER_ID = portalUserId;
1481
+ if (portalWorkspaceId) interactiveEnv.KNOXIS_WORKSPACE_ID = portalWorkspaceId;
1482
+ if (portalTaskIds && portalTaskIds.length) interactiveEnv.KNOXIS_TASK_IDS = portalTaskIds.join(',');
1483
+ if (productSlug) interactiveEnv.KNOXIS_PRODUCT_SLUG = productSlug;
1484
+ if (projectSlug) interactiveEnv.KNOXIS_PROJECT_SLUG = projectSlug;
1485
+ command = buildEnvCommand(interactiveEnv, `node "${scriptPath}"`);
1351
1486
  console.log(` 🤝 Interactive mode: ${scriptPath}`);
1352
1487
  } else {
1353
1488
  command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
@@ -1406,6 +1541,28 @@ function connectRelayWebSocket() {
1406
1541
  }
1407
1542
  } else if (msg.type === 'connected') {
1408
1543
  console.log(`🤝 Backend acknowledged: ${msg.message}`);
1544
+ } else if (msg.type === 'portal_config') {
1545
+ // Backend is auto-distributing the portal-sync token + URL so devs
1546
+ // don't paste them by hand. Merge into ~/.knoxis/config.json so
1547
+ // portal-sync.js picks them up on the next session save.
1548
+ try {
1549
+ ensureKnoxisDir();
1550
+ const cfgPath = path.join(KNOXIS_DIR, 'config.json');
1551
+ let existing = {};
1552
+ try {
1553
+ if (fs.existsSync(cfgPath)) existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) || {};
1554
+ } catch (_) {}
1555
+ if (typeof msg.portalToken === 'string' && msg.portalToken) existing.portalToken = msg.portalToken;
1556
+ if (typeof msg.portalUrl === 'string' && msg.portalUrl) existing.portalUrl = msg.portalUrl;
1557
+ if (typeof msg.portalSessionsPath === 'string' && msg.portalSessionsPath) {
1558
+ existing.portalSessionsPath = msg.portalSessionsPath;
1559
+ }
1560
+ fs.writeFileSync(cfgPath, JSON.stringify(existing, null, 2));
1561
+ // Don't log the token itself.
1562
+ console.log(`🔐 Portal-sync config received (token len ${(msg.portalToken || '').length})`);
1563
+ } catch (cfgErr) {
1564
+ console.warn(`⚠️ Failed to persist portal_config: ${cfgErr.message}`);
1565
+ }
1409
1566
  }
1410
1567
  } catch (e) {
1411
1568
  console.error('❌ Relay message handling error:', e.message);