supipowers 1.2.6 → 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.
Files changed (137) hide show
  1. package/README.md +118 -56
  2. package/bin/install.ts +48 -128
  3. package/package.json +11 -3
  4. package/skills/code-review/SKILL.md +137 -40
  5. package/skills/context-mode/SKILL.md +67 -56
  6. package/skills/creating-supi-agents/SKILL.md +204 -0
  7. package/skills/debugging/SKILL.md +86 -40
  8. package/skills/fix-pr/SKILL.md +96 -65
  9. package/skills/planning/SKILL.md +103 -46
  10. package/skills/qa-strategy/SKILL.md +68 -46
  11. package/skills/receiving-code-review/SKILL.md +60 -53
  12. package/skills/release/SKILL.md +111 -39
  13. package/skills/tdd/SKILL.md +118 -67
  14. package/skills/verification/SKILL.md +71 -37
  15. package/src/bootstrap.ts +27 -5
  16. package/src/commands/agents.ts +249 -0
  17. package/src/commands/ai-review.ts +1113 -0
  18. package/src/commands/config.ts +224 -95
  19. package/src/commands/doctor.ts +19 -13
  20. package/src/commands/fix-pr.ts +8 -11
  21. package/src/commands/generate.ts +200 -0
  22. package/src/commands/model-picker.ts +5 -15
  23. package/src/commands/model.ts +4 -5
  24. package/src/commands/optimize-context.ts +202 -0
  25. package/src/commands/plan.ts +148 -92
  26. package/src/commands/qa.ts +14 -23
  27. package/src/commands/release.ts +504 -275
  28. package/src/commands/review.ts +643 -86
  29. package/src/commands/status.ts +44 -17
  30. package/src/commands/supi.ts +69 -41
  31. package/src/commands/update.ts +57 -2
  32. package/src/config/defaults.ts +6 -39
  33. package/src/config/loader.ts +388 -40
  34. package/src/config/model-resolver.ts +26 -22
  35. package/src/config/schema.ts +113 -48
  36. package/src/context/analyzer.ts +61 -2
  37. package/src/context/optimizer.ts +199 -0
  38. package/src/context-mode/compressor.ts +14 -11
  39. package/src/context-mode/detector.ts +16 -54
  40. package/src/context-mode/event-extractor.ts +45 -16
  41. package/src/context-mode/event-store.ts +225 -16
  42. package/src/context-mode/hooks.ts +195 -22
  43. package/src/context-mode/knowledge/chunker.ts +235 -0
  44. package/src/context-mode/knowledge/store.ts +187 -0
  45. package/src/context-mode/routing.ts +12 -23
  46. package/src/context-mode/sandbox/executor.ts +183 -0
  47. package/src/context-mode/sandbox/runners.ts +40 -0
  48. package/src/context-mode/snapshot-builder.ts +243 -7
  49. package/src/context-mode/tools.ts +440 -0
  50. package/src/context-mode/web/fetcher.ts +117 -0
  51. package/src/context-mode/web/html-to-md.ts +293 -0
  52. package/src/debug/logger.ts +107 -0
  53. package/src/deps/registry.ts +0 -20
  54. package/src/docs/drift.ts +454 -0
  55. package/src/fix-pr/fetch-comments.ts +66 -0
  56. package/src/git/commit-msg.ts +2 -1
  57. package/src/git/commit.ts +123 -141
  58. package/src/git/conventions.ts +2 -2
  59. package/src/git/status.ts +4 -1
  60. package/src/lsp/bridge.ts +138 -12
  61. package/src/planning/approval-flow.ts +125 -19
  62. package/src/planning/plan-writer-prompt.ts +4 -11
  63. package/src/planning/planning-ask-tool.ts +81 -0
  64. package/src/planning/prompt-builder.ts +9 -169
  65. package/src/planning/system-prompt.ts +290 -0
  66. package/src/platform/omp.ts +50 -4
  67. package/src/platform/progress.ts +182 -0
  68. package/src/platform/test-utils.ts +4 -1
  69. package/src/platform/tui-colors.ts +30 -0
  70. package/src/platform/types.ts +1 -0
  71. package/src/qa/detect-app-type.ts +102 -0
  72. package/src/qa/discover-routes.ts +353 -0
  73. package/src/quality/ai-session.ts +96 -0
  74. package/src/quality/ai-setup.ts +86 -0
  75. package/src/quality/gates/ai-review.ts +129 -0
  76. package/src/quality/gates/build.ts +8 -0
  77. package/src/quality/gates/command.ts +150 -0
  78. package/src/quality/gates/format.ts +28 -0
  79. package/src/quality/gates/lint.ts +22 -0
  80. package/src/quality/gates/lsp-diagnostics.ts +84 -0
  81. package/src/quality/gates/test-suite.ts +8 -0
  82. package/src/quality/gates/typecheck.ts +22 -0
  83. package/src/quality/registry.ts +25 -0
  84. package/src/quality/review-gates.ts +33 -0
  85. package/src/quality/runner.ts +268 -0
  86. package/src/quality/schemas.ts +48 -0
  87. package/src/quality/setup.ts +227 -0
  88. package/src/release/changelog.ts +7 -3
  89. package/src/release/channels/custom.ts +43 -0
  90. package/src/release/channels/gitea.ts +35 -0
  91. package/src/release/channels/github.ts +35 -0
  92. package/src/release/channels/gitlab.ts +35 -0
  93. package/src/release/channels/registry.ts +52 -0
  94. package/src/release/channels/types.ts +27 -0
  95. package/src/release/detector.ts +10 -63
  96. package/src/release/executor.ts +61 -51
  97. package/src/release/prompt.ts +38 -38
  98. package/src/release/version.ts +129 -10
  99. package/src/review/agent-loader.ts +331 -0
  100. package/src/review/consolidator.ts +180 -0
  101. package/src/review/default-agents/correctness.md +72 -0
  102. package/src/review/default-agents/maintainability.md +64 -0
  103. package/src/review/default-agents/security.md +67 -0
  104. package/src/review/fixer.ts +219 -0
  105. package/src/review/multi-agent-runner.ts +135 -0
  106. package/src/review/output.ts +147 -0
  107. package/src/review/prompts/agent-review-wrapper.md +36 -0
  108. package/src/review/prompts/fix-findings.md +32 -0
  109. package/src/review/prompts/fix-output-schema.md +18 -0
  110. package/src/review/prompts/invalid-output-retry.md +22 -0
  111. package/src/review/prompts/output-instructions.md +14 -0
  112. package/src/review/prompts/review-output-schema.md +38 -0
  113. package/src/review/prompts/single-review.md +53 -0
  114. package/src/review/prompts/validation-review.md +30 -0
  115. package/src/review/runner.ts +128 -0
  116. package/src/review/scope.ts +353 -0
  117. package/src/review/template.ts +15 -0
  118. package/src/review/types.ts +296 -0
  119. package/src/review/validator.ts +160 -0
  120. package/src/storage/plans.ts +5 -3
  121. package/src/storage/reports.ts +50 -7
  122. package/src/storage/review-sessions.ts +117 -0
  123. package/src/text.ts +19 -0
  124. package/src/types.ts +336 -26
  125. package/src/utils/paths.ts +39 -0
  126. package/src/visual/companion.ts +5 -3
  127. package/src/visual/start-server.ts +101 -0
  128. package/src/visual/stop-server.ts +39 -0
  129. package/bin/ctx-mode-wrapper.mjs +0 -66
  130. package/src/config/profiles.ts +0 -64
  131. package/src/context-mode/installer.ts +0 -38
  132. package/src/quality/ai-review-gate.ts +0 -43
  133. package/src/quality/gate-runner.ts +0 -67
  134. package/src/quality/lsp-gate.ts +0 -24
  135. package/src/quality/test-gate.ts +0 -39
  136. package/src/visual/scripts/start-server.sh +0 -98
  137. package/src/visual/scripts/stop-server.sh +0 -21
package/src/text.ts ADDED
@@ -0,0 +1,19 @@
1
+ /** Normalizes Windows CRLF line endings to LF. */
2
+ export function normalizeLineEndings(text: string): string {
3
+ return text.replace(/\r\n/g, "\n");
4
+ }
5
+
6
+ /** Removes a single outer markdown code fence wrapper when present. */
7
+ export function stripMarkdownCodeFence(text: string): string {
8
+ const trimmed = text.trim();
9
+ if (!trimmed.startsWith("```")) {
10
+ return trimmed;
11
+ }
12
+
13
+ const lines = trimmed.split("\n");
14
+ if (lines.length >= 3 && lines[0]!.startsWith("```") && lines[lines.length - 1] === "```") {
15
+ return lines.slice(1, -1).join("\n").trim();
16
+ }
17
+
18
+ return trimmed;
19
+ }
package/src/types.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import type { TSchema } from "@sinclair/typebox";
2
+ import type { AgentSession, AgentSessionOptions, ExecOptions, ExecResult } from "./platform/types.js";
3
+
1
4
  // src/types.ts — Shared type definitions for supipowers
2
5
 
3
6
  /** Task complexity level */
@@ -35,12 +38,14 @@ export interface Notification {
35
38
  detail?: string;
36
39
  }
37
40
 
38
- /** Quality gate result */
39
- export interface GateResult {
40
- gate: string;
41
- passed: boolean;
42
- issues: GateIssue[];
43
- }
41
+ /** Canonical quality gate identifiers */
42
+ export type CommandGateId = "lint" | "typecheck" | "format" | "test-suite" | "build";
43
+ export type GateId = "lsp-diagnostics" | CommandGateId;
44
+
45
+ export type ConfigScope = "project" | "global";
46
+
47
+ /** Aggregate gate execution status */
48
+ export type GateStatus = "passed" | "failed" | "skipped" | "blocked";
44
49
 
45
50
  /** A single issue from a quality gate */
46
51
  export interface GateIssue {
@@ -48,23 +53,315 @@ export interface GateIssue {
48
53
  message: string;
49
54
  file?: string;
50
55
  line?: number;
56
+ detail?: string;
57
+ }
58
+
59
+ /** A single quality gate result */
60
+ export interface GateResult {
61
+ gate: GateId;
62
+ status: GateStatus;
63
+ summary: string;
64
+ issues: GateIssue[];
65
+ metadata?: Record<string, unknown>;
66
+ }
67
+
68
+ /** Aggregate quality-gate summary */
69
+ export interface GateSummary {
70
+ passed: number;
71
+ failed: number;
72
+ skipped: number;
73
+ blocked: number;
51
74
  }
52
75
 
53
76
  /** Review report */
54
77
  export interface ReviewReport {
55
- profile: string;
56
78
  timestamp: string;
79
+ selectedGates: GateId[];
57
80
  gates: GateResult[];
58
- passed: boolean;
81
+ summary: GateSummary;
82
+ overallStatus: Exclude<GateStatus, "skipped">;
83
+ }
84
+
85
+ /** AI review pipeline levels */
86
+ export type ReviewLevel = "quick" | "deep" | "multi-agent";
87
+
88
+ /** Scope selection modes for /supi:review */
89
+ export type ReviewScopeMode = "pull-request" | "uncommitted" | "commit" | "custom";
90
+
91
+ /** Structured review output status */
92
+ export type ReviewOutputStatus = "passed" | "failed" | "blocked";
93
+
94
+ /** Review finding severity */
95
+ export type ReviewFindingSeverity = "error" | "warning" | "info";
96
+
97
+ /** Prioritization tier for review findings */
98
+ export type ReviewFindingPriority = "P0" | "P1" | "P2" | "P3";
99
+
100
+ /** Validation verdict for a review finding */
101
+ export type ReviewValidationVerdict = "confirmed" | "rejected" | "uncertain";
102
+
103
+ /** Review session lifecycle status */
104
+ export type ReviewSessionStatus = "running" | "completed" | "blocked" | "cancelled";
105
+
106
+ /** User decision after reviewing consolidated/current findings */
107
+ export type ReviewPostConsolidationAction =
108
+ | "fix-now"
109
+ | "document-only"
110
+ | "discuss-before-fixing";
111
+
112
+ /** File-level diff summary within a review scope */
113
+ export interface ReviewScopeFile {
114
+ path: string;
115
+ additions: number;
116
+ deletions: number;
117
+ diff: string;
118
+ }
119
+
120
+ /** Aggregate statistics for a review scope */
121
+ export interface ReviewScopeStats {
122
+ filesChanged: number;
123
+ excludedFiles: number;
124
+ additions: number;
125
+ deletions: number;
59
126
  }
60
127
 
128
+ /** Review scope selected by the command pipeline */
129
+ export interface ReviewScope {
130
+ mode: ReviewScopeMode;
131
+ description: string;
132
+ diff: string;
133
+ files: ReviewScopeFile[];
134
+ stats: ReviewScopeStats;
135
+ baseBranch?: string;
136
+ commit?: string;
137
+ customInstructions?: string;
138
+ }
139
+
140
+ /** Per-finding validation metadata */
141
+ export interface ReviewFindingValidation {
142
+ verdict: ReviewValidationVerdict;
143
+ reasoning: string;
144
+ validatedBy: string;
145
+ validatedAt: string;
146
+ }
147
+
148
+ /** Canonical review finding produced by AI reviewers */
149
+ export interface ReviewFinding {
150
+ id: string;
151
+ title: string;
152
+ severity: ReviewFindingSeverity;
153
+ priority: ReviewFindingPriority;
154
+ confidence: number;
155
+ file: string | null;
156
+ lineStart: number | null;
157
+ lineEnd: number | null;
158
+ body: string;
159
+ suggestion: string | null;
160
+ agent?: string;
161
+ validation?: ReviewFindingValidation;
162
+ }
163
+
164
+ /** Structured output returned by review agents */
165
+ export interface ReviewOutput {
166
+ findings: ReviewFinding[];
167
+ summary: string;
168
+ status: ReviewOutputStatus;
169
+ }
170
+
171
+ /** Config entry for one review agent */
172
+ export interface ReviewAgentConfig {
173
+ name: string;
174
+ enabled: boolean;
175
+ data: string;
176
+ model: string | null;
177
+ }
178
+
179
+ /** Top-level review agent pipeline config */
180
+ export interface ReviewAgentsConfig {
181
+ agents: ReviewAgentConfig[];
182
+ }
183
+
184
+ /** Loaded review agent definition from markdown frontmatter + prompt body */
185
+ export interface ReviewAgentDefinition {
186
+ name: string;
187
+ description: string;
188
+ focus: string | null;
189
+ prompt: string;
190
+ filePath: string;
191
+ }
192
+ /** Review agent definition combined with pipeline config */
193
+ export interface ConfiguredReviewAgent extends ReviewAgentDefinition {
194
+ enabled: boolean;
195
+ data: string;
196
+ model: string | null;
197
+ scope?: "global" | "project";
198
+ }
199
+
200
+
201
+
202
+ /** Persisted summary for one review iteration */
203
+ export interface ReviewIterationSummary {
204
+ iteration: number;
205
+ findings: number;
206
+ status: ReviewOutputStatus;
207
+ file: string;
208
+ createdAt: string;
209
+ }
210
+ /** Aggregate status of an auto-fix pass */
211
+ export type ReviewFixOutputStatus = "applied" | "partial" | "skipped" | "blocked";
212
+
213
+ /** Structured result returned by the review fixer */
214
+ export interface ReviewFixOutput {
215
+ fixes: ReviewFixRecord[];
216
+ summary: string;
217
+ status: ReviewFixOutputStatus;
218
+ }
219
+
220
+
221
+
222
+ /** Persisted auto-fix attempt */
223
+ export interface ReviewFixRecord {
224
+ findingIds: string[];
225
+ file: string | null;
226
+ status: "applied" | "skipped" | "failed";
227
+ summary: string;
228
+ }
229
+
230
+ /** Paths to persisted review session artifacts */
231
+ export interface ReviewSessionArtifacts {
232
+ scope: string;
233
+ iterationsDir: string;
234
+ agentsDir: string;
235
+ rawFindings?: string;
236
+ validatedFindings?: string;
237
+ consolidatedFindings?: string;
238
+ findingsReport?: string;
239
+ }
240
+
241
+ /** Persisted /supi:review session metadata */
242
+ export interface ReviewSession {
243
+ id: string;
244
+ createdAt: string;
245
+ updatedAt: string;
246
+ level: ReviewLevel;
247
+ status: ReviewSessionStatus;
248
+ scope: ReviewScope;
249
+ validateFindings: boolean;
250
+ consolidate: boolean;
251
+ postConsolidationAction: ReviewPostConsolidationAction | null;
252
+ maxIterations: number;
253
+ currentIteration: number;
254
+ iterations: ReviewIterationSummary[];
255
+ fixes: ReviewFixRecord[];
256
+ artifacts: ReviewSessionArtifacts;
257
+ agents: string[];
258
+ }
259
+
260
+
261
+ /** Config for the lsp-diagnostics gate */
262
+ export interface LspDiagnosticsGateConfig {
263
+ enabled: boolean;
264
+ }
265
+
266
+ /** Shared config for command-driven gates. */
267
+ export type CommandGateConfig =
268
+ | { enabled: false; command?: string | null }
269
+ | { enabled: true; command: string };
270
+
271
+ export type LintGateConfig = CommandGateConfig;
272
+ export type TypecheckGateConfig = CommandGateConfig;
273
+ export type FormatGateConfig = CommandGateConfig;
274
+ export type TestSuiteGateConfig = CommandGateConfig;
275
+ export type BuildGateConfig = CommandGateConfig;
276
+
277
+ /** Canonical quality gate config map */
278
+ export interface QualityGatesConfig {
279
+ "lsp-diagnostics"?: LspDiagnosticsGateConfig;
280
+ "lint"?: LintGateConfig;
281
+ "typecheck"?: TypecheckGateConfig;
282
+ "format"?: FormatGateConfig;
283
+ "test-suite"?: TestSuiteGateConfig;
284
+ "build"?: BuildGateConfig;
285
+ }
286
+
287
+ /** Gate filter options provided by commands */
288
+ export interface GateFilters {
289
+ only?: GateId[];
290
+ skip?: GateId[];
291
+ }
292
+
293
+ /** Project facts used by gate setup/detection */
294
+ export interface ProjectFacts {
295
+ cwd: string;
296
+ packageScripts: Record<string, string>;
297
+ lockfiles: string[];
298
+ activeTools: string[];
299
+ existingGates: QualityGatesConfig;
300
+ }
301
+
302
+ /** Recommendation returned by gate auto-detection */
303
+ export interface GateDetectionResult<TConfig = unknown> {
304
+ suggestedConfig: TConfig | null;
305
+ confidence: "high" | "medium" | "low";
306
+ reason: string;
307
+ }
308
+
309
+ /** Shared runtime context passed to each gate */
310
+ export interface GateExecutionContext {
311
+ cwd: string;
312
+ changedFiles: string[];
313
+ scopeFiles: string[];
314
+ fileScope: "changed-files" | "all-files";
315
+ exec: (cmd: string, args: string[], opts?: ExecOptions) => Promise<ExecResult>;
316
+ execShell: (command: string, opts?: ExecOptions) => Promise<ExecResult>;
317
+ getLspDiagnostics: (
318
+ scopeFiles: string[],
319
+ fileScope: GateExecutionContext["fileScope"]
320
+ ) => Promise<GateIssue[]>;
321
+ createAgentSession: (
322
+ opts: Pick<AgentSessionOptions, "cwd" | "model" | "thinkingLevel">
323
+ ) => Promise<AgentSession>;
324
+ activeTools: string[];
325
+ reviewModel?: Pick<ResolvedModel, "model" | "thinkingLevel">;
326
+ }
327
+
328
+ /** Registered quality gate contract */
329
+ export interface GateDefinition<TConfig> {
330
+ id: GateId;
331
+ description: string;
332
+ configSchema: TSchema;
333
+ detect(projectFacts: ProjectFacts): GateDetectionResult<TConfig> | null;
334
+ run(context: GateExecutionContext, config: TConfig): Promise<GateResult>;
335
+ }
336
+
337
+ /** A proposed quality-gate configuration */
338
+ export interface SetupProposal {
339
+ gates: QualityGatesConfig;
340
+ }
341
+
342
+ /** Result of running the setup flow */
343
+ export type SetupGatesResult =
344
+ | { status: "proposed"; proposal: SetupProposal }
345
+ | { status: "invalid"; proposal: SetupProposal; errors: string[] }
346
+ | { status: "cancelled"; proposal?: SetupProposal; errors?: string[] };
347
+
61
348
  // ── Release types ──────────────────────────────────────────
62
349
 
63
350
  /** Semantic version bump type */
64
351
  export type BumpType = "major" | "minor" | "patch";
65
352
 
66
- /** Release channel target */
67
- export type ReleaseChannel = "github" | "npm";
353
+ /** Release channel target (built-in IDs or user-defined custom channel IDs) */
354
+ export type ReleaseChannel = string;
355
+
356
+ /** User-defined custom release channel configuration */
357
+ export interface CustomChannelConfig {
358
+ /** Display name shown in the channel selection UI */
359
+ label: string;
360
+ /** Shell command template with ${tag}, ${version}, ${changelog} placeholders */
361
+ publishCommand: string;
362
+ /** Shell command to detect availability — exit code 0 means available. When omitted, always available. */
363
+ detectCommand?: string;
364
+ }
68
365
 
69
366
  /** A single parsed commit entry */
70
367
  export interface CommitEntry {
@@ -97,7 +394,7 @@ export interface ReleaseResult {
97
394
 
98
395
  /** Context-mode integration settings */
99
396
  export interface ContextModeConfig {
100
- /** Master toggle for all context-mode integration (default: true) */
397
+ /** Master toggle for all supi-context-mode integration (default: true) */
101
398
  enabled: boolean;
102
399
  /** Byte threshold above which tool results are compressed (default: 4096) */
103
400
  compressionThreshold: number;
@@ -170,33 +467,46 @@ export interface ResolvedModel {
170
467
  /** Config shape */
171
468
  export interface SupipowersConfig {
172
469
  version: string;
173
- defaultProfile: string;
470
+ quality: {
471
+ gates: QualityGatesConfig;
472
+ };
174
473
  lsp: {
175
474
  setupGuide: boolean;
176
475
  };
177
- notifications: {
178
- verbosity: "quiet" | "normal" | "verbose";
179
- };
180
476
  qa: {
181
477
  framework: string | null;
182
- command: string | null;
183
478
  e2e: boolean;
184
479
  };
185
480
  release: {
186
481
  channels: ReleaseChannel[];
482
+ /** Tag format template. Use ${version} as placeholder. Default: "v${version}" */
483
+ tagFormat: string;
484
+ /** User-defined custom release channels keyed by channel ID */
485
+ customChannels: Record<string, CustomChannelConfig>;
187
486
  };
188
487
  contextMode: ContextModeConfig;
189
488
  mcp: McpManagementConfig;
190
489
  }
191
490
 
192
- /** Profile shape */
193
- export interface Profile {
194
- name: string;
195
- gates: {
196
- lspDiagnostics: boolean;
197
- aiReview: { enabled: boolean; depth: "quick" | "deep" };
198
- codeQuality: boolean;
199
- testSuite: boolean;
200
- e2e: boolean;
201
- };
491
+
492
+ /** Persisted state for /supi:generate docs — tracks which docs are monitored and the last checked commit */
493
+ export interface DocDriftState {
494
+ trackedFiles: string[];
495
+ lastCommit: string | null;
496
+ lastRunAt: string | null;
497
+ }
498
+
499
+ /** A single drift finding from a sub-agent doc review */
500
+ export interface DriftFinding {
501
+ file: string;
502
+ description: string;
503
+ severity: "info" | "warning" | "error";
504
+ relatedFiles?: string[];
202
505
  }
506
+
507
+ /** Result of a headless doc-drift check (multi-agent pipeline) */
508
+ export interface DriftCheckResult {
509
+ drifted: boolean;
510
+ summary: string;
511
+ findings: DriftFinding[];
512
+ }
@@ -0,0 +1,39 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import * as path from "node:path";
3
+
4
+ /**
5
+ * Returns the directory of the calling module, correctly resolved on all
6
+ * platforms.
7
+ *
8
+ * The naive alternative — `path.dirname(new URL(import.meta.url).pathname)` —
9
+ * is broken on Windows: `URL.pathname` for a `file:///C:/foo/bar.ts` URL
10
+ * yields `/C:/foo/bar.ts` (leading slash before the drive letter), which
11
+ * `path.dirname` on Windows converts to a backslash path. Those backslashes,
12
+ * when later embedded in a bash command, are consumed as escape sequences and
13
+ * silently corrupt the path.
14
+ *
15
+ * `fileURLToPath` handles the Windows case correctly, stripping the leading
16
+ * slash and returning a proper `C:\foo\bar.ts` path.
17
+ *
18
+ * @param importMetaUrl Pass `import.meta.url` from the caller.
19
+ */
20
+ export function moduleDir(importMetaUrl: string): string {
21
+ return path.dirname(fileURLToPath(importMetaUrl));
22
+ }
23
+
24
+ /**
25
+ * Normalizes path separators to forward slashes so the path is safe to embed
26
+ * in bash commands or shell-script arguments on Windows.
27
+ *
28
+ * Git Bash (and WSL) on Windows interpret `\` as an escape character, so a
29
+ * native Windows path like `C:\Users\foo\bar` silently becomes `C:Usersfoobar`
30
+ * when passed as a bash argument. Forward slashes are accepted by both Git
31
+ * Bash and Node.js file APIs on Windows, so this conversion is safe to apply
32
+ * universally.
33
+ *
34
+ * Use on every path that will be passed to `platform.exec("bash", [...])` or
35
+ * embedded as a literal in an AI prompt that contains bash commands.
36
+ */
37
+ export function toBashPath(p: string): string {
38
+ return p.replace(/\\/g, "/");
39
+ }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { VisualServerInfo, VisualEvent } from "./types.js";
5
+ import { normalizeLineEndings } from "../text.js";
5
6
  import type { PlatformPaths } from "../platform/types.js";
6
7
 
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -32,7 +33,7 @@ export function readEvents(sessionDir: string): VisualEvent[] {
32
33
  const eventsFile = path.join(sessionDir, ".events");
33
34
  if (!fs.existsSync(eventsFile)) return [];
34
35
 
35
- const content = fs.readFileSync(eventsFile, "utf-8");
36
+ const content = normalizeLineEndings(fs.readFileSync(eventsFile, "utf-8"));
36
37
  const events: VisualEvent[] = [];
37
38
 
38
39
  for (const line of content.split("\n")) {
@@ -60,9 +61,10 @@ export function getScriptsDir(): string {
60
61
  return path.join(__dirname, "scripts");
61
62
  }
62
63
 
63
- /** Parse server info from start-server.sh JSON output */
64
+ /** Parse server info from launcher output or server logs. */
64
65
  export function parseServerInfo(stdout: string): VisualServerInfo | null {
65
- for (const line of stdout.split("\n")) {
66
+ const normalizedStdout = normalizeLineEndings(stdout);
67
+ for (const line of normalizedStdout.split("\n")) {
66
68
  if (!line.trim()) continue;
67
69
  try {
68
70
  const data = JSON.parse(line);
@@ -0,0 +1,101 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getScriptsDir, readServerInfo } from "./companion.js";
4
+ import { stopVisualServer } from "./stop-server.js";
5
+ import type { VisualServerInfo } from "./types.js";
6
+
7
+ const DEFAULT_HOST = "127.0.0.1";
8
+ const START_TIMEOUT_MS = 5_000;
9
+ const POLL_INTERVAL_MS = 100;
10
+
11
+ interface StartServerOptions {
12
+ sessionDir: string;
13
+ host?: string;
14
+ urlHost?: string;
15
+ }
16
+
17
+ /** Start the visual companion server and wait for its connection info. */
18
+ export async function startVisualServer(opts: StartServerOptions): Promise<VisualServerInfo | null> {
19
+ const host = opts.host ?? DEFAULT_HOST;
20
+ const urlHost = opts.urlHost ?? deriveUrlHost(host);
21
+ const scriptsDir = getScriptsDir();
22
+ const pidFile = path.join(opts.sessionDir, ".server.pid");
23
+
24
+ fs.mkdirSync(opts.sessionDir, { recursive: true });
25
+ stopVisualServer(opts.sessionDir);
26
+ cleanupStartupArtifacts(opts.sessionDir);
27
+
28
+ try {
29
+ const subprocess = Bun.spawn(["node", "index.js"], {
30
+ cwd: scriptsDir,
31
+ env: {
32
+ ...process.env,
33
+ SUPI_VISUAL_DIR: opts.sessionDir,
34
+ SUPI_VISUAL_HOST: host,
35
+ SUPI_VISUAL_URL_HOST: urlHost,
36
+ },
37
+ stdin: "ignore",
38
+ stdout: "ignore",
39
+ stderr: "ignore",
40
+ detached: true,
41
+ });
42
+
43
+ subprocess.unref();
44
+ fs.writeFileSync(pidFile, String(subprocess.pid));
45
+
46
+ const serverInfo = await pollForServerStart(opts.sessionDir, subprocess.pid, START_TIMEOUT_MS);
47
+ if (serverInfo) {
48
+ return serverInfo;
49
+ }
50
+
51
+ stopVisualServer(opts.sessionDir);
52
+ // stopVisualServer may leave artifacts when kill returns a non-ESRCH error;
53
+ // force-clean so the caller never inherits stale state.
54
+ fs.rmSync(pidFile, { force: true });
55
+ cleanupStartupArtifacts(opts.sessionDir);
56
+ return null;
57
+ } catch {
58
+ fs.rmSync(pidFile, { force: true });
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function deriveUrlHost(host: string): string {
64
+ return host === "127.0.0.1" || host === "localhost" ? "localhost" : host;
65
+ }
66
+
67
+ function cleanupStartupArtifacts(sessionDir: string): void {
68
+ fs.rmSync(path.join(sessionDir, ".server-info"), { force: true });
69
+ }
70
+
71
+ async function pollForServerStart(
72
+ sessionDir: string,
73
+ pid: number,
74
+ timeoutMs: number,
75
+ ): Promise<VisualServerInfo | null> {
76
+ const deadline = Date.now() + timeoutMs;
77
+
78
+ while (Date.now() < deadline) {
79
+ const serverInfo = readServerInfo(sessionDir);
80
+ if (serverInfo) {
81
+ return isProcessAlive(pid) ? serverInfo : null;
82
+ }
83
+
84
+ if (!isProcessAlive(pid)) {
85
+ return null;
86
+ }
87
+
88
+ await Bun.sleep(POLL_INTERVAL_MS);
89
+ }
90
+
91
+ return null;
92
+ }
93
+
94
+ function isProcessAlive(pid: number): boolean {
95
+ try {
96
+ process.kill(pid, 0);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
@@ -0,0 +1,39 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export function stopVisualServer(
5
+ sessionDir: string,
6
+ ): { status: "stopped" | "not_running" | "failed" } {
7
+ const pidFile = path.join(sessionDir, ".server.pid");
8
+
9
+ if (!fs.existsSync(pidFile)) {
10
+ return { status: "not_running" };
11
+ }
12
+
13
+ const raw = fs.readFileSync(pidFile, "utf-8").trim();
14
+ const pid = parseInt(raw, 10);
15
+
16
+ if (isNaN(pid)) {
17
+ // Corrupt PID file — clean up and treat as not running
18
+ cleanupServerArtifacts(sessionDir);
19
+ return { status: "not_running" };
20
+ }
21
+
22
+ try {
23
+ process.kill(pid);
24
+ } catch (error) {
25
+ const code = (error as NodeJS.ErrnoException).code;
26
+ if (code !== "ESRCH") {
27
+ return { status: "failed" };
28
+ }
29
+ }
30
+
31
+ cleanupServerArtifacts(sessionDir);
32
+ return { status: "stopped" };
33
+ }
34
+
35
+ function cleanupServerArtifacts(sessionDir: string): void {
36
+ fs.rmSync(path.join(sessionDir, ".server.pid"), { force: true });
37
+ fs.rmSync(path.join(sessionDir, ".server.log"), { force: true });
38
+ fs.rmSync(path.join(sessionDir, ".server-info"), { force: true });
39
+ }