vigthoria-cli 1.11.37 → 1.11.44

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.
@@ -2,6 +2,12 @@ import chalk from 'chalk';
2
2
  import { createSpinner } from '../utils/logger.js';
3
3
  import { APIClient } from '../utils/api.js';
4
4
  import { getDesktopBridgeStatus } from '../utils/desktop-bridge-client.js';
5
+ function displayEndpoint(endpoint) {
6
+ if (/^(?:wss?|https?):\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?/i.test(endpoint)) {
7
+ return 'local loopback endpoint';
8
+ }
9
+ return endpoint;
10
+ }
5
11
  export class BridgeCommand {
6
12
  api;
7
13
  constructor(config, logger) {
@@ -18,7 +24,7 @@ export class BridgeCommand {
18
24
  console.log(chalk.white('DevTools Bridge (browser, port 4016):'));
19
25
  console.log(chalk.gray(' Status: ') + (devtoolsBridge.ok ? chalk.green('Reachable') : chalk.yellow('Not running')));
20
26
  if (devtoolsBridge.ok) {
21
- console.log(chalk.gray(' Endpoint: ') + devtoolsBridge.endpoint);
27
+ console.log(chalk.gray(' Endpoint: ') + displayEndpoint(devtoolsBridge.endpoint));
22
28
  }
23
29
  else {
24
30
  const detail = String(devtoolsBridge.error || 'Connection refused').trim() || 'Connection refused';
@@ -31,7 +37,7 @@ export class BridgeCommand {
31
37
  console.log(chalk.white('Desktop Bridge (Windows desktop, port 49160):'));
32
38
  console.log(chalk.gray(' Status: ') + (desktopBridge.ok ? chalk.green('Reachable') : chalk.yellow('Not running')));
33
39
  if (desktopBridge.ok) {
34
- console.log(chalk.gray(' Endpoint: ') + desktopBridge.endpoint);
40
+ console.log(chalk.gray(' Endpoint: ') + displayEndpoint(desktopBridge.endpoint));
35
41
  if (desktopBridge.service)
36
42
  console.log(chalk.gray(' Service: ') + desktopBridge.service);
37
43
  if (desktopBridge.version)
@@ -68,6 +68,7 @@ export declare class ChatCommand {
68
68
  private hasOperatorAccess;
69
69
  private operatorAccessMessage;
70
70
  private getDefaultChatModel;
71
+ private isHardExplicitAgentModelSelection;
71
72
  private resolveInitialModel;
72
73
  private isLegacyAgentFallbackAllowed;
73
74
  private shouldRescueFailedAnalysis;
@@ -22,10 +22,25 @@ import { inferAgentTaskType as sharedInferAgentTaskType, inferAgentTaskTypeWithC
22
22
  import { resolveAgentRoute } from '../utils/agentRoute.js';
23
23
  import { runTemplateInstantPath } from '../utils/templateInstantPath.js';
24
24
  import { isV3StreamKeepaliveEvent } from '../utils/v3-stream-events.js';
25
+ // Stream health policy (corrected 2026-07-04 after TestFarm incident with
26
+ // Gideon Lenz / C:\vigthoria\Apps\monopoly): agentic tasks have no
27
+ // predictable duration, so there is intentionally NO overall wall-clock
28
+ // timeout -- DEFAULT_V3_AGENT_TIMEOUT_MS stays disabled (0) by default and
29
+ // must never kill an actively-streaming request just because it's "taking
30
+ // long". The only legitimate failure signal for SSE/chunked streams is
31
+ // silence: DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS aborts only when zero bytes
32
+ // arrive for a stretch, which is what "the model/agent has gone dark"
33
+ // actually looks like on the wire.
25
34
  const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
35
+ // Agentic tasks are open-ended by design — a build/refactor/analysis can
36
+ // legitimately take minutes or hours depending on scope. There must be NO
37
+ // overall wall-clock cap that kills an actively-streaming, healthy request.
38
+ // The only correct failure signal for SSE/streamed responses is silence:
39
+ // see DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS below, which aborts only when zero
40
+ // bytes arrive for a stretch — never based on total elapsed duration.
26
41
  const rawValue = process.env.VIGTHORIA_AGENT_TIMEOUT_MS || process.env.V3_AGENT_TIMEOUT_MS;
27
42
  if (!rawValue) {
28
- return 0;
43
+ return 0; // disabled — no overall duration cap
29
44
  }
30
45
  const parsed = Number.parseInt(rawValue, 10);
31
46
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
@@ -33,16 +48,35 @@ const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
33
48
  const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
34
49
  const rawValue = process.env.VIGTHORIA_AGENT_IDLE_TIMEOUT_MS || process.env.V3_AGENT_IDLE_TIMEOUT_MS;
35
50
  if (!rawValue) {
36
- return 0;
51
+ return 60_000; // 60s with zero new stream data => abort/retry
37
52
  }
38
53
  const parsed = Number.parseInt(rawValue, 10);
39
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
54
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 60_000;
40
55
  })();
41
56
  const DEFAULT_V3_AGENT_SOFT_TIMEOUT_MS = (() => {
42
57
  const rawValue = process.env.VIGTHORIA_AGENT_SOFT_TIMEOUT_MS || process.env.V3_AGENT_SOFT_TIMEOUT_MS || '300000';
43
58
  const parsed = Number.parseInt(rawValue, 10);
44
59
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 300000;
45
60
  })();
61
+ /** Block-write agent JSON to VIGTHORIA_AGENT_OUTPUT (Windows KVM fsync path). */
62
+ function emitAgentJsonOutput(payload) {
63
+ const text = `${JSON.stringify(payload, null, 2)}\n`;
64
+ console.log(text.trimEnd());
65
+ const outPath = process.env.VIGTHORIA_AGENT_OUTPUT?.trim();
66
+ if (!outPath) {
67
+ return;
68
+ }
69
+ const dir = path.dirname(outPath);
70
+ fs.mkdirSync(dir, { recursive: true });
71
+ const fd = fs.openSync(outPath, 'w');
72
+ try {
73
+ fs.writeSync(fd, text, undefined, 'utf8');
74
+ fs.fsyncSync(fd);
75
+ }
76
+ finally {
77
+ fs.closeSync(fd);
78
+ }
79
+ }
46
80
  export class ChatCommand {
47
81
  config;
48
82
  logger;
@@ -251,6 +285,19 @@ export class ChatCommand {
251
285
  }
252
286
  return preferredModel;
253
287
  }
288
+ isHardExplicitAgentModelSelection(options) {
289
+ const requestedModel = String(options.model || '').trim().toLowerCase();
290
+ if (!requestedModel) {
291
+ return false;
292
+ }
293
+ if (/^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_FORCE_EXPLICIT_AGENT_MODEL || ''))) {
294
+ return true;
295
+ }
296
+ if (options.agent === true || options.operator === true) {
297
+ return !['agent', 'code', 'balanced', 'vigthoria-agent', 'vigthoria-code'].includes(requestedModel);
298
+ }
299
+ return true;
300
+ }
254
301
  resolveInitialModel(options) {
255
302
  const requestedModel = String(options.model || '').trim();
256
303
  if (requestedModel) {
@@ -312,6 +359,16 @@ export class ChatCommand {
312
359
  routeReason: 'explicit-model-selection',
313
360
  };
314
361
  }
362
+ if (this.agentMode && ['agent', 'code', 'balanced', 'vigthoria-agent', 'vigthoria-code'].includes(String(this.currentModel || '').toLowerCase())) {
363
+ return {
364
+ selectedModel: 'agent',
365
+ explicitModel: false,
366
+ heavyTask,
367
+ cloudEligible,
368
+ cloudSelected: false,
369
+ routeReason: 'dispatcher-eligible-agent-default',
370
+ };
371
+ }
315
372
  if (this.lastAgentRoute?.path === 'template-instant') {
316
373
  return {
317
374
  selectedModel: 'agent',
@@ -816,7 +873,12 @@ export class ChatCommand {
816
873
  }
817
874
  this.v3IdleWatchInterval = setInterval(() => {
818
875
  const idleMs = Date.now() - this.v3LastActivity;
819
- if (idleMs < 15_000 || this.v3IdleNoticeShown) {
876
+ if (idleMs < 15_000) {
877
+ // Activity resumed — re-arm the notice so a later stall is reported too.
878
+ this.v3IdleNoticeShown = false;
879
+ return;
880
+ }
881
+ if (this.v3IdleNoticeShown) {
820
882
  return;
821
883
  }
822
884
  this.v3IdleNoticeShown = true;
@@ -824,7 +886,11 @@ export class ChatCommand {
824
886
  spinner.stop();
825
887
  }
826
888
  const seconds = Math.round(idleMs / 1000);
827
- process.stderr.write(chalk.yellow(` [Wait] Model still working (${seconds}s) large context or server retry may take up to 90s...\n`));
889
+ const idleTimeoutSec = Math.round(DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS / 1000);
890
+ process.stderr.write(chalk.yellow(` [Wait] Model still working (${seconds}s) — this can legitimately take a while for large tasks. ` +
891
+ (idleTimeoutSec > 0
892
+ ? `Only complete silence (~${idleTimeoutSec}s with zero bytes received) will trigger an auto-recover/retry — active progress is never interrupted.\n`
893
+ : `\n`)));
828
894
  spinner.start();
829
895
  spinner.text = 'Waiting for model response...';
830
896
  }, 5_000);
@@ -1406,7 +1472,7 @@ export class ChatCommand {
1406
1472
  if (!this.config.isAuthenticated()) {
1407
1473
  if (options.json) {
1408
1474
  process.exitCode = 1;
1409
- console.log(JSON.stringify({ success: false, error: 'Not authenticated. Run: vigthoria login' }, null, 2));
1475
+ emitAgentJsonOutput({ success: false, error: 'Not authenticated. Run: vigthoria login' });
1410
1476
  }
1411
1477
  else {
1412
1478
  this.logger.error('Not authenticated. Run: vigthoria login');
@@ -1422,7 +1488,7 @@ export class ChatCommand {
1422
1488
  this.jsonOutput = options.json === true;
1423
1489
  this.autoApprove = options.autoApprove === true || this.jsonOutput;
1424
1490
  this.personaOverride = options.grant === true ? 'wiener_grant' : null;
1425
- this.modelExplicitlySelected = Boolean(String(options.model || '').trim());
1491
+ this.modelExplicitlySelected = this.isHardExplicitAgentModelSelection(options);
1426
1492
  this.currentModel = this.resolveInitialModel(options);
1427
1493
  this.applyNoAgentGovernance(String(options.model || this.currentModel || ''));
1428
1494
  this.currentProjectPath = this.resolveProjectPath(options);
@@ -2788,6 +2854,28 @@ export class ChatCommand {
2788
2854
  console.log(chalk.gray(' Run: vigthoria preview'));
2789
2855
  this.printAgentRunSummary(this.lastAgentRunOutcome, evaluation, 1);
2790
2856
  }
2857
+ else {
2858
+ const routingPolicy = this.resolveAgentExecutionPolicy(prompt);
2859
+ console.log(JSON.stringify({
2860
+ success: evaluation.executorSucceeded,
2861
+ mode: 'agent',
2862
+ model: routingPolicy.selectedModel,
2863
+ routingPolicy,
2864
+ taskId: null,
2865
+ contextId: null,
2866
+ partial: false,
2867
+ content: summary,
2868
+ statusHeadline: evaluation.statusHeadline,
2869
+ tasksSucceeded: 1,
2870
+ tasksTotal: 1,
2871
+ metadata: {
2872
+ source: 'template-instant',
2873
+ templateName: result.templateName,
2874
+ processingMs: result.processingMs,
2875
+ route: this.lastAgentRoute,
2876
+ },
2877
+ }, null, 2));
2878
+ }
2791
2879
  return true;
2792
2880
  }
2793
2881
  async tryDirectSingleFileFlow(prompt) {
@@ -3328,7 +3416,8 @@ export class ChatCommand {
3328
3416
  console.log(this.renderAgentAnswerForTerminal(finalAnswer));
3329
3417
  }
3330
3418
  }
3331
- if (!executorSucceeded && requiresWorkspaceChanges && changedFileCount === 0 && !liveOutcome.executorError) {
3419
+ const plannerFailedBeforeTasks = Boolean(liveOutcome.plannerError) && liveOutcome.tasksTotal === 0;
3420
+ if (!executorSucceeded && requiresWorkspaceChanges && changedFileCount === 0 && !liveOutcome.executorError && !plannerFailedBeforeTasks) {
3332
3421
  const clientToolErrors = this.api.getClientToolErrors();
3333
3422
  const transportErrors = this.api.getLastChatTransportErrors();
3334
3423
  if (clientToolErrors.length > 0) {
@@ -3344,6 +3433,12 @@ export class ChatCommand {
3344
3433
  liveOutcome.plannerError = transportErrors[0];
3345
3434
  }
3346
3435
  }
3436
+ if (plannerFailedBeforeTasks && !liveOutcome.executorError) {
3437
+ const transportErrors = this.api.getLastChatTransportErrors();
3438
+ if (transportErrors.length > 0) {
3439
+ liveOutcome.executorError = transportErrors[0];
3440
+ }
3441
+ }
3347
3442
  if (executorSucceeded) {
3348
3443
  taskDisplay.complete(1);
3349
3444
  }
@@ -3437,7 +3532,7 @@ export class ChatCommand {
3437
3532
  if (!executorSucceeded) {
3438
3533
  process.exitCode = 1;
3439
3534
  }
3440
- console.log(JSON.stringify({
3535
+ emitAgentJsonOutput({
3441
3536
  success: executorSucceeded,
3442
3537
  mode: 'agent',
3443
3538
  model: routingPolicy.selectedModel,
@@ -3459,7 +3554,7 @@ export class ChatCommand {
3459
3554
  },
3460
3555
  previewGate,
3461
3556
  },
3462
- }, null, 2));
3557
+ });
3463
3558
  }
3464
3559
  this.messages.push({ role: 'assistant', content: finalAnswer || runEvaluation.statusHeadline });
3465
3560
  this.stopV3IdleWatch();
package/dist/index.js CHANGED
@@ -54,6 +54,8 @@ const __filename = fileURLToPath(import.meta.url);
54
54
  const __dirname = path.dirname(__filename);
55
55
  import { APIClient, sanitizeUserFacingErrorText } from './utils/api.js';
56
56
  import { getCliStateFile, isOfflineMode, isSafeNpmPackageSpec, isUpdateCheckSuppressed, readCachedLatestVersion, readGatewayPreflightCache, writeCachedLatestVersion, writeGatewayPreflightCache, } from './utils/cli-state.js';
57
+ import { applyLocalTestfarmDefaults, isLocalTestfarmMode } from './utils/localTestMode.js';
58
+ applyLocalTestfarmDefaults();
57
59
  function isApiError(error) {
58
60
  return Boolean(error &&
59
61
  typeof error === 'object' &&
@@ -398,6 +400,9 @@ function isAuthProtectedCommand(command) {
398
400
  return protectedCommands.has(command);
399
401
  }
400
402
  async function enforceGatewayAuthSession(config, logger, jsonOutputRequested) {
403
+ if (isLocalTestfarmMode()) {
404
+ return true;
405
+ }
401
406
  if (!config.isAuthenticated()) {
402
407
  return true;
403
408
  }
@@ -3,6 +3,13 @@
3
3
  */
4
4
  import { inferAgentTaskType, isTrivialHtmlPageRequest, isAgentContinuePrompt, isAgentRetryPrompt, hasGameIntent, hasWebPageIntent, taskRequiresWorkspaceChanges, stripExecutionShaping, } from './requestIntent.js';
5
5
  import { isFastInferenceRouterAvailable, routeWithBalanced4b, shouldUseBalancedRouter, } from './fastAgentRouter.js';
6
+ function forceTemplateScaffold() {
7
+ const v = String(process.env.VIGTHORIA_FORCE_TEMPLATE_SCAFFOLD || '').trim().toLowerCase();
8
+ return v === '1' || v === 'true' || v === 'yes';
9
+ }
10
+ function wantsInstantTemplatePath(prompt) {
11
+ return /instant[- ]template|template[- ]instant|use the instant-template path/i.test(prompt);
12
+ }
6
13
  function normalizeTaskKind(value, fallback) {
7
14
  const kind = String(value || fallback).trim().toLowerCase();
8
15
  const allowed = new Set([
@@ -115,6 +122,16 @@ export async function resolveAgentRoute(api, prompt) {
115
122
  };
116
123
  }
117
124
  if (hasGameIntent(stripped) && regexTask === 'game-build') {
125
+ if (forceTemplateScaffold() || wantsInstantTemplatePath(stripped)) {
126
+ return {
127
+ path: 'template-instant',
128
+ taskKind: 'game-build',
129
+ fastPath: 'template-instant',
130
+ confidence: 0.98,
131
+ reason: 'express game template scaffold',
132
+ source: 'regex',
133
+ };
134
+ }
118
135
  return {
119
136
  path: 'v3-agent',
120
137
  taskKind: 'game-build',
@@ -256,6 +256,7 @@ export declare class APIClient {
256
256
  valid: boolean;
257
257
  error?: string;
258
258
  }>;
259
+ private isDirectV3AgentBaseUrl;
259
260
  getV3AgentBaseUrls(preferLocal?: boolean): string[];
260
261
  getV3AgentRunUrl(baseUrl: string): string;
261
262
  getV3AgentContinueUrl(baseUrl: string): string;
package/dist/utils/api.js CHANGED
@@ -12,10 +12,12 @@ import WebSocket from 'ws';
12
12
  import { buildSemanticContext } from './context-ranker.js';
13
13
  import { getChangedFiles } from './workspace-cache.js';
14
14
  import { isSubstantiveAgentAnswer, isToolEvidenceStubAnswer } from './agentRunOutcome.js';
15
- import { joinV3WorkspacePath, normalizeV3WorkspaceRelativePath } from './v3-workspace-path.js';
15
+ import { isV3WorkspaceRootAliasPath, joinV3WorkspacePath, normalizeV3WorkspaceRelativePath } from './v3-workspace-path.js';
16
16
  import { buildExecutionHints, isPlannerBuildTask, resolvePlannerAgentTimeoutMs } from './requestIntent.js';
17
17
  import { isV3StreamKeepaliveEvent } from './v3-stream-events.js';
18
18
  import { resolveV3ContextCharLimit, WORKSPACE_FILE_CHAR_CAP, } from './contextBudget.js';
19
+ import { isLocalTestfarmMode, fetchWithServiceTimeout } from './localTestMode.js';
20
+ import { buildClientManifest } from './clientManifest.js';
19
21
  export const VIGTHORIA_HUB_CREDITS_URL = 'https://hub.vigthoria.io/credits';
20
22
  export const VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE = 'Vigthoria Server is temporarily not available. Please try again later. If the issue persists, please contact support.';
21
23
  export class CLIError extends Error {
@@ -258,10 +260,21 @@ export function propagateError(err) {
258
260
  },
259
261
  };
260
262
  }
263
+ // Stream health policy — see matching comment in src/commands/chat.ts.
264
+ // No overall wall-clock timeout for agentic requests (duration is
265
+ // unpredictable and must never kill a healthy, actively-streaming request).
266
+ // Only silence (zero bytes for a stretch, see DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS)
267
+ // is a legitimate failure signal for SSE/chunked streams.
261
268
  const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
269
+ // Agentic tasks are open-ended by design — a build/refactor/analysis can
270
+ // legitimately take minutes or hours depending on scope. There must be NO
271
+ // overall wall-clock cap that kills an actively-streaming, healthy request.
272
+ // The only correct failure signal for SSE/streamed responses is silence:
273
+ // see DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS below, which aborts only when zero
274
+ // bytes arrive for a stretch — never based on total elapsed duration.
262
275
  const rawValue = process.env.VIGTHORIA_AGENT_TIMEOUT_MS || process.env.V3_AGENT_TIMEOUT_MS;
263
276
  if (!rawValue) {
264
- return 0;
277
+ return 0; // disabled — no overall duration cap
265
278
  }
266
279
  const parsed = Number.parseInt(rawValue, 10);
267
280
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
@@ -269,10 +282,10 @@ const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
269
282
  const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
270
283
  const rawValue = process.env.VIGTHORIA_AGENT_IDLE_TIMEOUT_MS || process.env.V3_AGENT_IDLE_TIMEOUT_MS;
271
284
  if (!rawValue) {
272
- return 0;
285
+ return 60_000; // 60s with zero new stream data => abort/retry
273
286
  }
274
287
  const parsed = Number.parseInt(rawValue, 10);
275
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
288
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 60_000;
276
289
  })();
277
290
  const DEFAULT_OPERATOR_TIMEOUT_MS = (() => {
278
291
  const rawValue = process.env.VIGTHORIA_OPERATOR_TIMEOUT_MS || process.env.OPERATOR_TIMEOUT_MS;
@@ -559,6 +572,16 @@ export class APIClient {
559
572
  const enforceTokenShape = options.enforceTokenShape !== false;
560
573
  const explicitEnvToken = Boolean(process.env.VIGTHORIA_TOKEN || process.env.VIGTHORIA_AUTH_TOKEN);
561
574
  const token = this.getAccessToken();
575
+ if (isLocalTestfarmMode()) {
576
+ if (!token) {
577
+ return { valid: false, error: 'Local test mode requires VIGTHORIA_LOCAL_TEST_TOKEN or VIGTHORIA_AUTH_TOKEN' };
578
+ }
579
+ const expected = String(process.env.VIGTHORIA_LOCAL_TEST_TOKEN || '').trim();
580
+ if (expected && token !== expected) {
581
+ return { valid: false, error: 'VIGTHORIA_LOCAL_TEST_TOKEN mismatch' };
582
+ }
583
+ return { valid: true };
584
+ }
562
585
  if (!token) {
563
586
  return { valid: false, error: 'No auth token configured. Run: vigthoria login' };
564
587
  }
@@ -617,8 +640,18 @@ export class APIClient {
617
640
  // Both unreachable — don't assume the stored token is bad when running offline.
618
641
  return { valid: true };
619
642
  }
643
+ isDirectV3AgentBaseUrl(baseUrl) {
644
+ try {
645
+ const parsed = new URL(String(baseUrl));
646
+ return parsed.port === '8030' || parsed.port.endsWith('8030');
647
+ }
648
+ catch {
649
+ return /(?:127\.0\.0\.1|localhost|172\.19\.0\.1|host\.lan):(?:\d*8030|8030)/.test(String(baseUrl));
650
+ }
651
+ }
620
652
  getV3AgentBaseUrls(preferLocal = false) {
621
653
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
654
+ const localTestMode = isLocalTestfarmMode();
622
655
  const includeLoopbackV3Agent = process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT === '1'
623
656
  || (preferLocal && isServerRuntime());
624
657
  const localCandidates = [
@@ -626,30 +659,32 @@ export class APIClient {
626
659
  process.env.V3_AGENT_URL,
627
660
  ...(includeLoopbackV3Agent ? ['http://127.0.0.1:8030'] : []),
628
661
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
629
- const remoteCandidates = [
630
- configuredApiUrl,
631
- 'https://coder.vigthoria.io',
632
- ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
662
+ const remoteCandidates = localTestMode
663
+ ? [configuredApiUrl]
664
+ : [configuredApiUrl, 'https://coder.vigthoria.io'];
665
+ const normalizedRemote = remoteCandidates
666
+ .filter(Boolean)
667
+ .map((url) => String(url).replace(/\/$/, ''));
633
668
  const urls = preferLocal && localCandidates.length > 0
634
- ? [...localCandidates, ...remoteCandidates]
635
- : [...remoteCandidates, ...localCandidates];
669
+ ? [...localCandidates, ...normalizedRemote]
670
+ : [...normalizedRemote, ...localCandidates];
636
671
  return [...new Set(urls)];
637
672
  }
638
673
  getV3AgentRunUrl(baseUrl) {
639
- if (/127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)) {
674
+ if (this.isDirectV3AgentBaseUrl(baseUrl)) {
640
675
  return `${baseUrl}/api/agent/run`;
641
676
  }
642
677
  return `${baseUrl}/api/v3-agent/run`;
643
678
  }
644
679
  getV3AgentContinueUrl(baseUrl) {
645
- if (/127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)) {
680
+ if (this.isDirectV3AgentBaseUrl(baseUrl)) {
646
681
  return `${baseUrl}/api/agent/continue`;
647
682
  }
648
683
  return `${baseUrl}/api/v3-agent/continue`;
649
684
  }
650
685
  getV3AgentBackgroundUrl(baseUrl, suffix = '') {
651
686
  const cleanSuffix = suffix ? `/${suffix.replace(/^\/+/, '')}` : '';
652
- if (/127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)) {
687
+ if (this.isDirectV3AgentBaseUrl(baseUrl)) {
653
688
  return `${baseUrl}/api/agent/background${cleanSuffix}`;
654
689
  }
655
690
  return `${baseUrl}/api/v3-agent/background${cleanSuffix}`;
@@ -670,13 +705,20 @@ export class APIClient {
670
705
  }
671
706
  getMcpBaseUrls() {
672
707
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
673
- const urls = [
674
- process.env.VIGTHORIA_MCP_URL,
675
- process.env.MCP_SERVER_URL,
676
- 'http://127.0.0.1:4008',
677
- configuredApiUrl,
678
- ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
679
- return [...new Set(urls)];
708
+ const localTestMode = isLocalTestfarmMode();
709
+ const urls = localTestMode
710
+ ? [
711
+ process.env.VIGTHORIA_MCP_URL,
712
+ process.env.MCP_SERVER_URL,
713
+ configuredApiUrl,
714
+ ]
715
+ : [
716
+ process.env.VIGTHORIA_MCP_URL,
717
+ process.env.MCP_SERVER_URL,
718
+ 'http://127.0.0.1:4008',
719
+ configuredApiUrl,
720
+ ];
721
+ return [...new Set(urls.filter(Boolean).map((url) => String(url).replace(/\/$/, '')))];
680
722
  }
681
723
  getVigFlowBaseUrls() {
682
724
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
@@ -1165,44 +1207,52 @@ export class APIClient {
1165
1207
  async runV3HealthCheck() {
1166
1208
  const endpoints = this.getV3AgentBaseUrls(false);
1167
1209
  const headers = await this.getV3AgentHeaders();
1210
+ const failures = [];
1168
1211
  for (const baseUrl of endpoints) {
1169
1212
  const runUrl = this.getV3AgentRunUrl(baseUrl);
1170
1213
  // Use the lightweight GET /health endpoint — no LLM invocation, responds instantly.
1171
1214
  // For local 8030 the path is /health directly; for remote it's /api/v3-agent/health via proxy.
1172
- const healthUrl = /127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)
1215
+ const healthUrl = this.isDirectV3AgentBaseUrl(baseUrl)
1173
1216
  ? `${baseUrl}/health`
1174
1217
  : `${baseUrl}/api/v3-agent/health`;
1175
- try {
1176
- const response = await fetch(healthUrl, {
1177
- method: 'GET',
1178
- headers,
1179
- signal: AbortSignal.timeout(8000),
1180
- });
1181
- if (response.status === 401 || response.status === 403) {
1182
- return {
1183
- healthy: false,
1184
- endpoint: runUrl,
1185
- error: 'V3 service rejected authentication. Please re-login to refresh your token.',
1186
- };
1218
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1219
+ try {
1220
+ const response = await fetch(healthUrl, {
1221
+ method: 'GET',
1222
+ headers,
1223
+ signal: AbortSignal.timeout(8000),
1224
+ });
1225
+ if (response.status === 401 || response.status === 403) {
1226
+ return {
1227
+ healthy: false,
1228
+ endpoint: runUrl,
1229
+ error: 'V3 service rejected authentication. Please re-login to refresh your token.',
1230
+ };
1231
+ }
1232
+ // 200 OK or 405 Method Not Allowed both mean V3 is reachable.
1233
+ if (response.ok || response.status === 405) {
1234
+ return { healthy: true, endpoint: runUrl };
1235
+ }
1236
+ failures.push(`${healthUrl} attempt ${attempt}: HTTP ${response.status}`);
1237
+ }
1238
+ catch (error) {
1239
+ const reason = sanitizeUserFacingErrorText(error?.message || String(error)).slice(0, 160);
1240
+ failures.push(`${healthUrl} attempt ${attempt}: ${reason || 'network error'}`);
1187
1241
  }
1188
- // 200 OK or 405 Method Not Allowed both mean V3 is reachable
1189
- if (response.ok || response.status === 405) {
1190
- return { healthy: true, endpoint: runUrl };
1242
+ if (attempt < 3) {
1243
+ await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
1191
1244
  }
1192
1245
  }
1193
- catch {
1194
- continue;
1195
- }
1196
1246
  }
1197
1247
  return {
1198
1248
  healthy: false,
1199
1249
  endpoint: endpoints[0] ? this.getV3AgentRunUrl(endpoints[0]) : 'unknown',
1200
- error: 'V3 service is not reachable during startup preflight. Check if V3 Code Agent is online and reachable.',
1250
+ error: `V3 service is not reachable during startup preflight. Checked ${endpoints.length} endpoint(s). ${failures.slice(-3).join(' | ')}`,
1201
1251
  };
1202
1252
  }
1203
1253
  async runV3AgentAuthPreflight(baseUrl, body, executionContext) {
1204
1254
  const endpoint = this.getV3AgentRunUrl(baseUrl);
1205
- const healthEndpoint = /127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)
1255
+ const healthEndpoint = this.isDirectV3AgentBaseUrl(baseUrl)
1206
1256
  ? `${baseUrl}/health`
1207
1257
  : `${baseUrl}/api/v3-agent/health`;
1208
1258
  const headers = await this.getV3AgentHeaders();
@@ -1579,6 +1629,9 @@ export class APIClient {
1579
1629
  codebaseContext: resolvedContext.codebaseContext || '',
1580
1630
  accountBrainContext: resolvedContext.accountBrainContext || '',
1581
1631
  projectMemory: resolvedContext.projectMemory || '',
1632
+ clientManifest: buildClientManifest(resolvedContext.clientManifest && typeof resolvedContext.clientManifest === 'object'
1633
+ ? resolvedContext.clientManifest
1634
+ : {}),
1582
1635
  };
1583
1636
  return this.compactV3Context(payload);
1584
1637
  }
@@ -2853,7 +2906,10 @@ menu {
2853
2906
  }
2854
2907
  }
2855
2908
  if (!relativePath && raw !== '.' && raw !== '') {
2856
- return null;
2909
+ const treatAsWorkspaceRoot = roots.some((sourceRoot) => isV3WorkspaceRootAliasPath(raw, sourceRoot));
2910
+ if (!treatAsWorkspaceRoot) {
2911
+ return null;
2912
+ }
2857
2913
  }
2858
2914
  const absolutePath = relativePath ? joinV3WorkspacePath(rootPath, relativePath) : path.resolve(rootPath);
2859
2915
  const resolvedRoot = path.resolve(rootPath);
@@ -3776,6 +3832,18 @@ document.addEventListener('DOMContentLoaded', () => {
3776
3832
  else {
3777
3833
  chunk = await new Promise((resolve, reject) => {
3778
3834
  let idleTimer = null;
3835
+ // Idle/silence detection (NOT a task-duration timeout): this timer
3836
+ // only ever fires after idleTimeoutMs with ZERO new bytes on the
3837
+ // wire. What we do next depends on WHY it's silent:
3838
+ // - A client-side tool is genuinely still running (e.g. a local
3839
+ // build/install the CLI itself launched) -- its own duration is
3840
+ // unpredictable by design, so we reschedule indefinitely; this
3841
+ // is known, bounded-elsewhere work, not "the agent went dark".
3842
+ // - Otherwise, silence really does mean nothing is coming from
3843
+ // the agent, so after a generous grace window we recover
3844
+ // (if the workspace already has output) or fail cleanly.
3845
+ let idleRescheduleCount = 0;
3846
+ const maxIdleReschedules = 20; // ~20 min of genuine zero-byte silence
3779
3847
  const clearIdleTimer = () => {
3780
3848
  if (idleTimer) {
3781
3849
  clearTimeout(idleTimer);
@@ -3786,6 +3854,8 @@ document.addEventListener('DOMContentLoaded', () => {
3786
3854
  clearIdleTimer();
3787
3855
  idleTimer = setTimeout(() => {
3788
3856
  if (this.pendingV3ClientToolTasks.size > 0) {
3857
+ // Known local work in progress -- never cap this, its own
3858
+ // completion will produce the next chunk/event.
3789
3859
  scheduleIdleTimer();
3790
3860
  return;
3791
3861
  }
@@ -3803,6 +3873,22 @@ document.addEventListener('DOMContentLoaded', () => {
3803
3873
  reject(stalledError);
3804
3874
  return;
3805
3875
  }
3876
+ if (idleRescheduleCount >= maxIdleReschedules) {
3877
+ const timeoutError = new Error(`V3 agent stream timed out after ${Math.round((idleTimeoutMs * (maxIdleReschedules + 1)) / 1000)}s with no data ` +
3878
+ '(model likely stopped after a thinking block without emitting a tool call or final answer).');
3879
+ timeoutError.name = 'AbortError';
3880
+ timeoutError.partialData = {
3881
+ task_id: events.find((event) => event && event.task_id)?.task_id || null,
3882
+ context_id: contextId,
3883
+ result: final,
3884
+ events,
3885
+ files: streamedFiles,
3886
+ };
3887
+ clearIdleTimer();
3888
+ reject(timeoutError);
3889
+ return;
3890
+ }
3891
+ idleRescheduleCount += 1;
3806
3892
  scheduleIdleTimer();
3807
3893
  }, idleTimeoutMs);
3808
3894
  };
@@ -4382,6 +4468,8 @@ document.addEventListener('DOMContentLoaded', () => {
4382
4468
  // ground its analysis in real files rather than returning files_analyzed: 0.
4383
4469
  const workspacePath = executionContext.workspacePath || executionContext.projectPath || executionContext.targetPath || process.cwd();
4384
4470
  const workspaceSummary = this.buildLocalWorkspaceSummary(workspacePath);
4471
+ const requestedModel = String(executionContext.model || 'code');
4472
+ const resolvedModel = this.resolveModelId(requestedModel);
4385
4473
  for (const baseUrl of this.getOperatorBaseUrls()) {
4386
4474
  const controller = new AbortController();
4387
4475
  const timeoutId = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
@@ -4401,6 +4489,8 @@ document.addEventListener('DOMContentLoaded', () => {
4401
4489
  prompt: message,
4402
4490
  task: message,
4403
4491
  raw_prompt: executionContext.rawPrompt || null,
4492
+ model: requestedModel,
4493
+ preferred_model: resolvedModel,
4404
4494
  context_id: executionContext.contextId,
4405
4495
  trace_id: executionContext.traceId,
4406
4496
  mcp_context_id: executionContext.mcpContextId || null,
@@ -4408,7 +4498,7 @@ document.addEventListener('DOMContentLoaded', () => {
4408
4498
  workspace: { path: workspacePath },
4409
4499
  workspace_path: workspacePath,
4410
4500
  workspace_summary: workspaceSummary,
4411
- model: this.resolveModelId(executionContext.model || 'code'),
4501
+ model: resolvedModel,
4412
4502
  history: executionContext.history || [],
4413
4503
  executionSurface: executionContext.executionSurface || 'cli',
4414
4504
  clientSurface: executionContext.clientSurface || 'cli',
@@ -5915,7 +6005,7 @@ document.addEventListener('DOMContentLoaded', () => {
5915
6005
  return;
5916
6006
  }
5917
6007
  const headers = await this.getV3AgentHeaders();
5918
- const endpoint = /127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)
6008
+ const endpoint = this.isDirectV3AgentBaseUrl(baseUrl)
5919
6009
  ? `${baseUrl}/api/agent/client-tool-result`
5920
6010
  : `${baseUrl}/api/v3-agent/client-tool-result`;
5921
6011
  const body = JSON.stringify({
@@ -6132,7 +6222,7 @@ document.addEventListener('DOMContentLoaded', () => {
6132
6222
  const endpoint = process.env.VIGTHORIA_HYPERLOOP_URL || `${configuredApiUrl}/api/hyperloop/health`;
6133
6223
  try {
6134
6224
  const token = this.getAccessToken();
6135
- const response = await fetch(endpoint, {
6225
+ const response = await fetchWithServiceTimeout(endpoint, {
6136
6226
  method: 'GET',
6137
6227
  headers: token ? { Authorization: `Bearer ${token}` } : undefined,
6138
6228
  });
@@ -6164,7 +6254,7 @@ document.addEventListener('DOMContentLoaded', () => {
6164
6254
  const token = this.getAccessToken();
6165
6255
  const projectPath = this.resolveAgentTargetPath(context);
6166
6256
  try {
6167
- const modulesResponse = await fetch(modulesEndpoint, {
6257
+ const modulesResponse = await fetchWithServiceTimeout(modulesEndpoint, {
6168
6258
  method: 'GET',
6169
6259
  headers: {
6170
6260
  ...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -6178,7 +6268,7 @@ document.addEventListener('DOMContentLoaded', () => {
6178
6268
  const compactor = modules.find((entry) => entry && entry.name === 'repo_context_compactor');
6179
6269
  let compactContextLength = 0;
6180
6270
  try {
6181
- const probeResponse = await fetch(endpoint, {
6271
+ const probeResponse = await fetchWithServiceTimeout(endpoint, {
6182
6272
  method: 'POST',
6183
6273
  headers: {
6184
6274
  'Content-Type': 'application/json',
@@ -6260,6 +6350,33 @@ document.addEventListener('DOMContentLoaded', () => {
6260
6350
  const host = process.env.VIGTHORIA_DEVTOOLS_BRIDGE_HOST || '127.0.0.1';
6261
6351
  const port = Number.parseInt(process.env.VIGTHORIA_DEVTOOLS_BRIDGE_PORT || '4016', 10);
6262
6352
  const endpoint = `ws://${host}:${port}/ws`;
6353
+ const healthEndpoint = `http://${host}:${port}/health`;
6354
+ try {
6355
+ const controller = new AbortController();
6356
+ const timer = setTimeout(() => controller.abort(), 1500);
6357
+ const response = await fetch(healthEndpoint, {
6358
+ signal: controller.signal,
6359
+ headers: { Accept: 'application/json' },
6360
+ });
6361
+ clearTimeout(timer);
6362
+ if (response.ok) {
6363
+ return {
6364
+ name: 'DevTools Bridge',
6365
+ endpoint,
6366
+ ok: true,
6367
+ details: { host, port, healthEndpoint },
6368
+ };
6369
+ }
6370
+ return {
6371
+ name: 'DevTools Bridge',
6372
+ endpoint,
6373
+ ok: false,
6374
+ error: `HTTP ${response.status}`,
6375
+ };
6376
+ }
6377
+ catch {
6378
+ // Fall back to a raw TCP probe for older bridge builds without /health.
6379
+ }
6263
6380
  return new Promise((resolve) => {
6264
6381
  const socket = net.connect({ host, port, timeout: 1500 }, () => {
6265
6382
  socket.end();
@@ -0,0 +1,12 @@
1
+ export type ClientManifestPayload = {
2
+ product_id: string;
3
+ version: string;
4
+ os: string;
5
+ execution_mode: string;
6
+ features_allowed: string[];
7
+ };
8
+ /** Detect iSH/Blink-style iOS terminal sandboxes (override, UA, or Linux-on-iOS release string). */
9
+ export declare function isIOSEnvironment(): boolean;
10
+ /** Map Node process.platform/arch to dispatcher gate OS slug. */
11
+ export declare function resolveClientOsSlug(): string;
12
+ export declare function buildClientManifest(overrides?: Partial<ClientManifestPayload>): ClientManifestPayload;
@@ -0,0 +1,68 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+ const DEFAULT_FEATURES = ['template-instant', 'hyperloop-stream'];
8
+ function readPackageVersion() {
9
+ const candidates = [
10
+ path.join(__dirname, '..', '..', 'package.json'),
11
+ path.join(__dirname, '..', 'package.json'),
12
+ ];
13
+ for (const candidate of candidates) {
14
+ try {
15
+ if (!fs.existsSync(candidate))
16
+ continue;
17
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
18
+ if (parsed.version)
19
+ return String(parsed.version);
20
+ }
21
+ catch {
22
+ // try next candidate
23
+ }
24
+ }
25
+ return '0.0.0';
26
+ }
27
+ /** Detect iSH/Blink-style iOS terminal sandboxes (override, UA, or Linux-on-iOS release string). */
28
+ export function isIOSEnvironment() {
29
+ const override = (process.env.VIGTHORIA_OS_OVERRIDE || '').trim().toLowerCase();
30
+ if (override === 'ios')
31
+ return true;
32
+ const userAgent = (process.env.VIGTHORIA_CLIENT_USER_AGENT || '').trim();
33
+ if (/ipad|iphone|ios/i.test(userAgent))
34
+ return true;
35
+ if (process.platform === 'linux') {
36
+ const release = os.release().toLowerCase();
37
+ if (/ipad|iphone|ios/.test(release))
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ /** Map Node process.platform/arch to dispatcher gate OS slug. */
43
+ export function resolveClientOsSlug() {
44
+ if (isIOSEnvironment()) {
45
+ return process.arch === 'arm64' ? 'ios-arm64' : 'ios-x64';
46
+ }
47
+ if (process.platform === 'win32')
48
+ return 'windows-x86_64';
49
+ if (process.platform === 'darwin') {
50
+ return process.arch === 'arm64' ? 'macos-arm64' : 'macos-x86_64';
51
+ }
52
+ if (process.platform === 'linux') {
53
+ return process.arch === 'arm64' ? 'linux-arm64' : 'linux-x86_64';
54
+ }
55
+ return `${process.platform}-${process.arch}`;
56
+ }
57
+ export function buildClientManifest(overrides = {}) {
58
+ const ios = isIOSEnvironment() && !overrides.os;
59
+ return {
60
+ product_id: overrides.product_id || 'vigthoria-cli',
61
+ version: overrides.version || readPackageVersion(),
62
+ os: overrides.os || resolveClientOsSlug(),
63
+ execution_mode: overrides.execution_mode || (ios ? 'mobile-terminal-sandbox' : 'local-user-machine'),
64
+ features_allowed: overrides.features_allowed?.length
65
+ ? [...overrides.features_allowed]
66
+ : [...DEFAULT_FEATURES],
67
+ };
68
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Testfarm / Admin Factory local E2E — bypass public coder.vigthoria.io gateway
3
+ * when VIGTHORIA_LOCAL_TEST_MODE=1 or VIGTHORIA_LOCAL_TEST_TOKEN is set.
4
+ */
5
+ export declare function isLocalTestfarmMode(): boolean;
6
+ /** Apply once at CLI startup — idempotent via ??= */
7
+ export declare function applyLocalTestfarmDefaults(): void;
8
+ export declare function resolveHyperloopTimeoutMs(): number;
9
+ export declare function fetchWithServiceTimeout(url: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Testfarm / Admin Factory local E2E — bypass public coder.vigthoria.io gateway
3
+ * when VIGTHORIA_LOCAL_TEST_MODE=1 or VIGTHORIA_LOCAL_TEST_TOKEN is set.
4
+ */
5
+ export function isLocalTestfarmMode() {
6
+ const flag = String(process.env.VIGTHORIA_LOCAL_TEST_MODE || '').trim().toLowerCase();
7
+ if (flag === '1' || flag === 'true' || flag === 'yes') {
8
+ return true;
9
+ }
10
+ return Boolean(String(process.env.VIGTHORIA_LOCAL_TEST_TOKEN || '').trim());
11
+ }
12
+ /** Apply once at CLI startup — idempotent via ??= */
13
+ export function applyLocalTestfarmDefaults() {
14
+ if (!isLocalTestfarmMode()) {
15
+ return;
16
+ }
17
+ process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES ??= '1';
18
+ process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT ??= '1';
19
+ process.env.VIGTHORIA_SELF_HOSTED_MODELS_API_URL ??= 'http://127.0.0.1:4009';
20
+ process.env.VIGTHORIA_HYPERLOOP_URL ??= 'http://127.0.0.1:8020';
21
+ process.env.VIGTHORIA_HYPERLOOP_EXECUTE_URL ??= 'http://127.0.0.1:8020/execute';
22
+ process.env.VIGTHORIA_HYPERLOOP_MODULES_URL ??= 'http://127.0.0.1:8020/modules';
23
+ process.env.VIGTHORIA_V3_AGENT_URL ??= 'http://127.0.0.1:8030';
24
+ process.env.VIGTHORIA_TEMPLATE_SERVICE_URL ??= 'http://127.0.0.1:4011';
25
+ process.env.VIGTHORIA_HYPERLOOP_TIMEOUT_MS ??= '180000';
26
+ const testToken = String(process.env.VIGTHORIA_LOCAL_TEST_TOKEN || '').trim();
27
+ if (testToken && !process.env.VIGTHORIA_AUTH_TOKEN && !process.env.VIGTHORIA_TOKEN) {
28
+ process.env.VIGTHORIA_AUTH_TOKEN = testToken;
29
+ }
30
+ }
31
+ export function resolveHyperloopTimeoutMs() {
32
+ const raw = process.env.VIGTHORIA_HYPERLOOP_TIMEOUT_MS
33
+ || process.env.VIGTHORIA_HTTP_TIMEOUT_MS
34
+ || '120000';
35
+ const parsed = Number.parseInt(String(raw), 10);
36
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 120000;
37
+ }
38
+ export async function fetchWithServiceTimeout(url, init = {}) {
39
+ const ms = resolveHyperloopTimeoutMs();
40
+ const signal = init.signal ?? AbortSignal.timeout(ms);
41
+ return fetch(url, { ...init, signal });
42
+ }
@@ -83,6 +83,7 @@ export declare class SessionManager {
83
83
  * Load session by ID
84
84
  */
85
85
  load(id: string): Session | null;
86
+ private normalizeProjectKey;
86
87
  /**
87
88
  * Get the most recent session for a project
88
89
  */
@@ -251,15 +251,48 @@ export class SessionManager {
251
251
  return null;
252
252
  }
253
253
  }
254
+ normalizeProjectKey(projectPath) {
255
+ const raw = String(projectPath || '').trim();
256
+ if (!raw) {
257
+ return '';
258
+ }
259
+ // Normalize separators and collapse trailing slashes.
260
+ let normalized = raw.replace(/\\/g, '/').replace(/\/+$/g, '');
261
+ // Canonicalize obvious Windows drive prefix casing (C:/... === c:/...).
262
+ const driveMatch = normalized.match(/^([a-zA-Z]):\//);
263
+ if (driveMatch) {
264
+ normalized = `${driveMatch[1].toUpperCase()}:${normalized.slice(2)}`;
265
+ }
266
+ // Windows paths are case-insensitive in practice for this CLI matching purpose.
267
+ if (/^[a-zA-Z]:\//.test(normalized) || normalized.startsWith('//')) {
268
+ return normalized.toLowerCase();
269
+ }
270
+ return normalized;
271
+ }
254
272
  /**
255
273
  * Get the most recent session for a project
256
274
  */
257
275
  getLatest(project) {
258
276
  const sessions = this.list();
259
- const projectSessions = sessions
260
- .filter(s => s.project === project)
277
+ const projectKey = this.normalizeProjectKey(project);
278
+ const projectBase = path.basename(projectKey || '').toLowerCase();
279
+ const exactMatches = sessions
280
+ .filter((s) => this.normalizeProjectKey(s.project) === projectKey)
261
281
  .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
262
- return projectSessions.length > 0 ? this.load(projectSessions[0].id) : null;
282
+ if (exactMatches.length > 0) {
283
+ return this.load(exactMatches[0].id);
284
+ }
285
+ // Fallback: if old sessions stored inconsistent slash/case forms, allow
286
+ // latest same-folder match (e.g., C:\Proj and C:/proj) to preserve /retry UX.
287
+ if (projectBase) {
288
+ const softMatches = sessions
289
+ .filter((s) => path.basename(this.normalizeProjectKey(s.project) || '').toLowerCase() === projectBase)
290
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
291
+ if (softMatches.length > 0) {
292
+ return this.load(softMatches[0].id);
293
+ }
294
+ }
295
+ return null;
263
296
  }
264
297
  /**
265
298
  * List all sessions (metadata only) — corrupt or unreadable files are
@@ -170,6 +170,24 @@ export async function runTemplateInstantPath(api, vision, workspacePath, options
170
170
  const target = path.join(workspacePath, entryPath);
171
171
  const cleanVision = stripExecutionShaping(vision);
172
172
  const preferMinimal = options.forceMinimalFallback === true;
173
+ const keepScaffold = String(process.env.VIGTHORIA_EXPRESS_KEEP_SCAFFOLD || process.env.CLI_EXPRESS_KEEP_SCAFFOLD || '').trim().toLowerCase();
174
+ if (['1', 'true', 'yes'].includes(keepScaffold)) {
175
+ try {
176
+ const stat = fs.statSync(target);
177
+ if (stat.isFile() && stat.size >= 8000) {
178
+ return {
179
+ ok: true,
180
+ entryPath,
181
+ templateName: 'express-preserved-scaffold',
182
+ confidence: 1,
183
+ processingMs: 0,
184
+ };
185
+ }
186
+ }
187
+ catch {
188
+ // fall through to normal template match
189
+ }
190
+ }
173
191
  let html;
174
192
  let usedFallback = false;
175
193
  let match = {};
@@ -5,3 +5,5 @@
5
5
  export declare function normalizeV3WorkspaceRelativePath(rawPath: string, rootPath?: string): string;
6
6
  /** Join workspace root + relative path without path.resolve URI corruption on Windows. */
7
7
  export declare function joinV3WorkspacePath(rootPath: string, relativePath: string): string;
8
+ /** True when rawPath points to workspace root via aliases or scrubbed placeholders. */
9
+ export declare function isV3WorkspaceRootAliasPath(rawPath: string, rootPath?: string): boolean;
@@ -1,4 +1,12 @@
1
1
  import path from 'path';
2
+ const WORKSPACE_PLACEHOLDER_SEGMENT = /\[(?:internal|Vigthoria service|Vigthoria Agent|Vigthoria storage|temp|home|workspace)\]/ig;
3
+ function stripInlineWorkspacePlaceholders(value) {
4
+ // Only strip placeholders when they were appended to a segment (e.g. pitfall[Vigthoria service]).
5
+ // Keep canonical scrubbed segments like /[internal]/ intact for dedicated mapping logic.
6
+ return String(value || '')
7
+ .replace(/(^|[^/])\[(?:internal|Vigthoria service|Vigthoria Agent|Vigthoria storage|temp|home|workspace)\](?=(?:\/|$))/ig, '$1')
8
+ .replace(/\/+$/g, '');
9
+ }
2
10
  /**
3
11
  * Normalize V3 SSE / tool paths to a safe workspace-relative POSIX path.
4
12
  * Decodes vigthoria:// boundary URIs from server scrubbing before local writes.
@@ -8,6 +16,7 @@ export function normalizeV3WorkspaceRelativePath(rawPath, rootPath) {
8
16
  if (!input) {
9
17
  return '';
10
18
  }
19
+ input = stripInlineWorkspacePlaceholders(input);
11
20
  const safeRelative = (candidate) => {
12
21
  const stripped = String(candidate || '').replace(/^\/+/, '');
13
22
  if (!stripped) {
@@ -43,7 +52,7 @@ export function normalizeV3WorkspaceRelativePath(rawPath, rootPath) {
43
52
  return null;
44
53
  };
45
54
  const stripScrubbedPlaceholder = (value) => {
46
- let candidate = String(value || '').replace(/^\/+/, '');
55
+ let candidate = stripInlineWorkspacePlaceholders(String(value || '')).replace(/^\/+/, '');
47
56
  // Hybrid paths models echo back after SSE scrubbing, e.g. C:/var/www/[internal]/game.js
48
57
  const hybridWin = candidate.match(/^[a-zA-Z]:\/(?:var\/)?www\/\[internal\](?:\/(.*))?$/i);
49
58
  if (hybridWin) {
@@ -70,7 +79,7 @@ export function normalizeV3WorkspaceRelativePath(rawPath, rootPath) {
70
79
  return safeRelative(match[groupIndex] || '');
71
80
  }
72
81
  }
73
- const bracketSplit = candidate.split(/\[(?:internal|Vigthoria service|Vigthoria Agent|temp|home|workspace)\]/i);
82
+ const bracketSplit = candidate.split(WORKSPACE_PLACEHOLDER_SEGMENT);
74
83
  if (bracketSplit.length > 1) {
75
84
  const tail = bracketSplit[bracketSplit.length - 1].replace(/^\/+/, '');
76
85
  return safeRelative(tail);
@@ -152,3 +161,34 @@ export function joinV3WorkspacePath(rootPath, relativePath) {
152
161
  const segments = relativePath.split('/').filter((segment) => segment && segment !== '.');
153
162
  return path.join(rootPath, ...segments);
154
163
  }
164
+ /** True when rawPath points to workspace root via aliases or scrubbed placeholders. */
165
+ export function isV3WorkspaceRootAliasPath(rawPath, rootPath) {
166
+ const original = String(rawPath || '').trim();
167
+ if (!original || original === '.') {
168
+ return true;
169
+ }
170
+ const sanitized = stripInlineWorkspacePlaceholders(original).replace(/\\/g, '/').trim();
171
+ if (!sanitized || sanitized === '.' || sanitized === '/') {
172
+ return true;
173
+ }
174
+ const candidate = sanitized.replace(/^\/+/, '');
175
+ if (!candidate) {
176
+ return true;
177
+ }
178
+ const lowered = candidate.toLowerCase();
179
+ if (lowered === 'workspace'
180
+ || lowered === '[workspace]'
181
+ || lowered === '[internal]'
182
+ || lowered === 'www/[internal]'
183
+ || lowered === 'var/www/[internal]'
184
+ || lowered === '[vigthoria service]'
185
+ || lowered === 'var/www/[vigthoria service]') {
186
+ return true;
187
+ }
188
+ const normalizedRoot = String(rootPath || '').trim().replace(/\\/g, '/').replace(/\/+$/g, '');
189
+ if (!normalizedRoot) {
190
+ return false;
191
+ }
192
+ const normalizedInput = sanitized.replace(/\/+$/g, '');
193
+ return normalizedInput.toLowerCase() === normalizedRoot.toLowerCase();
194
+ }
package/install.ps1 CHANGED
@@ -5,7 +5,7 @@
5
5
  $ErrorActionPreference = "Stop"
6
6
 
7
7
  # Configuration
8
- $CLI_VERSION = "1.11.25"
8
+ $CLI_VERSION = "1.11.42"
9
9
  $INSTALL_DIR = "$env:USERPROFILE\.vigthoria"
10
10
  $NPM_PACKAGE = "vigthoria-cli"
11
11
  $GIT_PACKAGE_URL = "git+https://market.vigthoria.io/vigthoria/vigthoria-cli.git"
package/install.sh CHANGED
@@ -26,7 +26,7 @@ else
26
26
  fi
27
27
 
28
28
  # Configuration
29
- CLI_VERSION="1.11.25"
29
+ CLI_VERSION="1.11.42"
30
30
  INSTALL_DIR="$HOME/.vigthoria"
31
31
  REPO_URL="https://market.vigthoria.io/vigthoria/vigthoria-cli"
32
32
  GIT_PACKAGE_URL="git+https://market.vigthoria.io/vigthoria/vigthoria-cli.git"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigthoria-cli",
3
- "version": "1.11.37",
3
+ "version": "1.11.44",
4
4
  "description": "Vigthoria Coder CLI - AI-powered terminal coding assistant",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -70,6 +70,7 @@
70
70
  "test:legion:billing:e2e": "npm run build && node scripts/test-legion-godmode-billing-e2e.js",
71
71
  "test:windows:v3-sync": "npm run build && node scripts/test-windows-v3-sync-recovery.js",
72
72
  "test:v3-workspace-path": "npm run build && node scripts/test-v3-workspace-path.js",
73
+ "test:session:project-match": "npm run build && node scripts/test-session-project-match.mjs",
73
74
  "test:v3-stream-mutation": "npm run build && node scripts/test-v3-stream-mutation.js",
74
75
  "test:context:budget": "npm run build && node scripts/test-context-budget.js",
75
76
  "test:pitfall:context": "npm run build && node scripts/test-pitfall-context-smoke.js"
@@ -122,7 +122,10 @@ PY
122
122
  echo "[7.5 policy] secured hyperloop endpoint"
123
123
  code=$(curl -s -o /dev/null -w "%{http_code}" https://coder.vigthoria.io/api/hyperloop/health)
124
124
  echo "unauth_hyperloop_code=$code"
125
- [[ "$code" == "401" ]]
125
+ # 401 = correct JSON auth error for API callers. 302 (redirect to /login) is
126
+ # also acceptable defense-in-depth: it still requires auth, just via the
127
+ # browser-session redirect path some legacy middleware uses.
128
+ [[ "$code" == "401" || "$code" == "302" ]]
126
129
 
127
130
  echo "[8.2] runtime symlink guard signals"
128
131
  rg -n "realpathSync|results.length >= maxFiles|pattern = kw.length <= 4" dist/utils/context-ranker.js >/tmp/vig-ranker-signals.txt