@doriandev/devcc 0.1.2 → 0.3.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 (151) hide show
  1. package/README.md +589 -389
  2. package/bin/devcc.mjs +50 -23
  3. package/dist/devcc/agents/agents.d.ts +119 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -0
  5. package/dist/devcc/agents/agents.js +182 -0
  6. package/dist/devcc/agents/agents.js.map +1 -0
  7. package/dist/devcc/app/App.d.ts +23 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +451 -19
  10. package/dist/devcc/app/App.js.map +1 -1
  11. package/dist/devcc/app/controlPlaneCommands.d.ts +14 -0
  12. package/dist/devcc/app/controlPlaneCommands.d.ts.map +1 -0
  13. package/dist/devcc/app/controlPlaneCommands.js +204 -0
  14. package/dist/devcc/app/controlPlaneCommands.js.map +1 -0
  15. package/dist/devcc/app/keymap.d.ts.map +1 -1
  16. package/dist/devcc/app/keymap.js +25 -3
  17. package/dist/devcc/app/keymap.js.map +1 -1
  18. package/dist/devcc/automation.d.ts +52 -0
  19. package/dist/devcc/automation.d.ts.map +1 -0
  20. package/dist/devcc/automation.js +339 -0
  21. package/dist/devcc/automation.js.map +1 -0
  22. package/dist/devcc/cli.d.ts.map +1 -1
  23. package/dist/devcc/cli.js +73 -3
  24. package/dist/devcc/cli.js.map +1 -1
  25. package/dist/devcc/components/Sidebar.d.ts.map +1 -1
  26. package/dist/devcc/components/Sidebar.js +1 -0
  27. package/dist/devcc/components/Sidebar.js.map +1 -1
  28. package/dist/devcc/components/primitives.d.ts.map +1 -1
  29. package/dist/devcc/components/primitives.js +3 -0
  30. package/dist/devcc/components/primitives.js.map +1 -1
  31. package/dist/devcc/core/commands.d.ts +33 -0
  32. package/dist/devcc/core/commands.d.ts.map +1 -1
  33. package/dist/devcc/core/commands.js.map +1 -1
  34. package/dist/devcc/core/config.d.ts +71 -3
  35. package/dist/devcc/core/config.d.ts.map +1 -1
  36. package/dist/devcc/core/config.js +189 -5
  37. package/dist/devcc/core/config.js.map +1 -1
  38. package/dist/devcc/core/state.d.ts +19 -1
  39. package/dist/devcc/core/state.d.ts.map +1 -1
  40. package/dist/devcc/core/state.js +6 -0
  41. package/dist/devcc/core/state.js.map +1 -1
  42. package/dist/devcc/environment/EnvironmentService.d.ts +58 -0
  43. package/dist/devcc/environment/EnvironmentService.d.ts.map +1 -0
  44. package/dist/devcc/environment/EnvironmentService.js +159 -0
  45. package/dist/devcc/environment/EnvironmentService.js.map +1 -0
  46. package/dist/devcc/environment/integrations.d.ts +52 -0
  47. package/dist/devcc/environment/integrations.d.ts.map +1 -0
  48. package/dist/devcc/environment/integrations.js +94 -0
  49. package/dist/devcc/environment/integrations.js.map +1 -0
  50. package/dist/devcc/environment/tools.d.ts +51 -0
  51. package/dist/devcc/environment/tools.d.ts.map +1 -0
  52. package/dist/devcc/environment/tools.js +99 -0
  53. package/dist/devcc/environment/tools.js.map +1 -0
  54. package/dist/devcc/git/GitService.d.ts +26 -0
  55. package/dist/devcc/git/GitService.d.ts.map +1 -1
  56. package/dist/devcc/git/GitService.js +71 -2
  57. package/dist/devcc/git/GitService.js.map +1 -1
  58. package/dist/devcc/git/worktrees.d.ts +88 -0
  59. package/dist/devcc/git/worktrees.d.ts.map +1 -0
  60. package/dist/devcc/git/worktrees.js +226 -0
  61. package/dist/devcc/git/worktrees.js.map +1 -0
  62. package/dist/devcc/index.d.ts +15 -3
  63. package/dist/devcc/index.d.ts.map +1 -1
  64. package/dist/devcc/index.js +14 -2
  65. package/dist/devcc/index.js.map +1 -1
  66. package/dist/devcc/init/detect.d.ts +55 -0
  67. package/dist/devcc/init/detect.d.ts.map +1 -0
  68. package/dist/devcc/init/detect.js +207 -0
  69. package/dist/devcc/init/detect.js.map +1 -0
  70. package/dist/devcc/init/prompt.d.ts +25 -0
  71. package/dist/devcc/init/prompt.d.ts.map +1 -0
  72. package/dist/devcc/init/prompt.js +156 -0
  73. package/dist/devcc/init/prompt.js.map +1 -0
  74. package/dist/devcc/platform/launch.d.ts +30 -0
  75. package/dist/devcc/platform/launch.d.ts.map +1 -0
  76. package/dist/devcc/platform/launch.js +93 -0
  77. package/dist/devcc/platform/launch.js.map +1 -0
  78. package/dist/devcc/services/ServiceManager.d.ts +10 -2
  79. package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
  80. package/dist/devcc/services/ServiceManager.js +18 -4
  81. package/dist/devcc/services/ServiceManager.js.map +1 -1
  82. package/dist/devcc/services/node.d.ts +11 -0
  83. package/dist/devcc/services/node.d.ts.map +1 -1
  84. package/dist/devcc/services/node.js +20 -18
  85. package/dist/devcc/services/node.js.map +1 -1
  86. package/dist/devcc/services/ollama.d.ts +64 -5
  87. package/dist/devcc/services/ollama.d.ts.map +1 -1
  88. package/dist/devcc/services/ollama.js +118 -17
  89. package/dist/devcc/services/ollama.js.map +1 -1
  90. package/dist/devcc/services/remote.d.ts +90 -0
  91. package/dist/devcc/services/remote.d.ts.map +1 -0
  92. package/dist/devcc/services/remote.js +233 -0
  93. package/dist/devcc/services/remote.js.map +1 -0
  94. package/dist/devcc/services/types.d.ts +8 -1
  95. package/dist/devcc/services/types.d.ts.map +1 -1
  96. package/dist/devcc/services/types.js.map +1 -1
  97. package/dist/devcc/utils/exec.d.ts +6 -0
  98. package/dist/devcc/utils/exec.d.ts.map +1 -1
  99. package/dist/devcc/utils/exec.js +12 -2
  100. package/dist/devcc/utils/exec.js.map +1 -1
  101. package/dist/devcc/verify/verify.d.ts +107 -0
  102. package/dist/devcc/verify/verify.d.ts.map +1 -0
  103. package/dist/devcc/verify/verify.js +203 -0
  104. package/dist/devcc/verify/verify.js.map +1 -0
  105. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  106. package/dist/devcc/views/OverviewView.js +58 -2
  107. package/dist/devcc/views/OverviewView.js.map +1 -1
  108. package/dist/devcc/views/ServicesView.d.ts +2 -0
  109. package/dist/devcc/views/ServicesView.d.ts.map +1 -1
  110. package/dist/devcc/views/ServicesView.js +15 -0
  111. package/dist/devcc/views/ServicesView.js.map +1 -1
  112. package/dist/devcc/views/SystemView.d.ts +10 -1
  113. package/dist/devcc/views/SystemView.d.ts.map +1 -1
  114. package/dist/devcc/views/SystemView.js +127 -19
  115. package/dist/devcc/views/SystemView.js.map +1 -1
  116. package/dist/devcc/views/WorktreesView.d.ts +48 -0
  117. package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
  118. package/dist/devcc/views/WorktreesView.js +235 -0
  119. package/dist/devcc/views/WorktreesView.js.map +1 -0
  120. package/package.json +1 -1
  121. package/src/devcc/agents/agents.ts +292 -0
  122. package/src/devcc/app/App.ts +529 -22
  123. package/src/devcc/app/controlPlaneCommands.ts +227 -0
  124. package/src/devcc/app/keymap.ts +25 -3
  125. package/src/devcc/automation.ts +409 -0
  126. package/src/devcc/cli.ts +77 -3
  127. package/src/devcc/components/Sidebar.ts +1 -0
  128. package/src/devcc/components/primitives.ts +3 -0
  129. package/src/devcc/core/commands.ts +34 -0
  130. package/src/devcc/core/config.ts +275 -11
  131. package/src/devcc/core/state.ts +25 -0
  132. package/src/devcc/environment/EnvironmentService.ts +215 -0
  133. package/src/devcc/environment/integrations.ts +133 -0
  134. package/src/devcc/environment/tools.ts +151 -0
  135. package/src/devcc/git/GitService.ts +104 -2
  136. package/src/devcc/git/worktrees.ts +256 -0
  137. package/src/devcc/index.ts +66 -2
  138. package/src/devcc/init/detect.ts +268 -0
  139. package/src/devcc/init/prompt.ts +179 -0
  140. package/src/devcc/platform/launch.ts +125 -0
  141. package/src/devcc/services/ServiceManager.ts +15 -3
  142. package/src/devcc/services/node.ts +17 -14
  143. package/src/devcc/services/ollama.ts +162 -19
  144. package/src/devcc/services/remote.ts +306 -0
  145. package/src/devcc/services/types.ts +9 -1
  146. package/src/devcc/utils/exec.ts +26 -2
  147. package/src/devcc/verify/verify.ts +316 -0
  148. package/src/devcc/views/OverviewView.ts +84 -2
  149. package/src/devcc/views/ServicesView.ts +17 -0
  150. package/src/devcc/views/SystemView.ts +144 -22
  151. package/src/devcc/views/WorktreesView.ts +270 -0
@@ -1,6 +1,10 @@
1
1
  import { BoxRenderable, createCliRenderer, type CliRenderer, type KeyEvent } from "@opentui/core"
2
- import { existsSync } from "node:fs"
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
3
3
  import { basename, join, resolve } from "node:path"
4
+ import { AgentManager, createAgentAdapters } from "../agents/agents.js"
5
+ import { configProblems } from "../automation.js"
6
+ import { detectProject, renderStarterConfig } from "../init/detect.js"
7
+ import { buildSetupPrompt, extractConfigFromOutput } from "../init/prompt.js"
4
8
  import { ConfirmDialog, PromptDialog } from "../components/ConfirmDialog.js"
5
9
  import { Footer, type KeyHint } from "../components/Footer.js"
6
10
  import { Header } from "../components/Header.js"
@@ -10,7 +14,8 @@ import { Sidebar } from "../components/Sidebar.js"
10
14
  import { CommandPalette } from "../components/CommandPalette.js"
11
15
  import { ProjectPicker } from "../components/ProjectPicker.js"
12
16
  import { CommandRegistry, type CommandContext } from "../core/commands.js"
13
- import { loadConfig, type CommandConfigEntry, type ResolvedConfig } from "../core/config.js"
17
+ import { loadConfig, loadConfigFile, type CommandConfigEntry, type ResolvedConfig } from "../core/config.js"
18
+ import { EnvironmentService, type EnvironmentSnapshot } from "../environment/EnvironmentService.js"
14
19
  import { Subscriptions } from "../core/events.js"
15
20
  import {
16
21
  createInitialSelection,
@@ -26,10 +31,12 @@ import {
26
31
  type NotificationLevel,
27
32
  type ProjectState,
28
33
  type ScriptRun,
34
+ type VerifyState,
29
35
  type ViewId,
30
36
  } from "../core/state.js"
31
37
  import { DependencyService, updateArgs } from "../deps/DependencyService.js"
32
38
  import { GitService } from "../git/GitService.js"
39
+ import { defaultWorktreePath, removalBlocker, worktreeLabel, type GitWorktree } from "../git/worktrees.js"
33
40
  import {
34
41
  buildProjectList,
35
42
  defaultSearchDirs,
@@ -40,20 +47,36 @@ import {
40
47
  } from "../projects/ProjectRegistry.js"
41
48
  import { LogStore, MAX_LOG_LINES } from "../logs/LogBuffer.js"
42
49
  import { ALL_SOURCES } from "../logs/merge.js"
43
- import { isActive } from "../processes/ManagedProcess.js"
50
+ import { isActive, type ManagedProcess } from "../processes/ManagedProcess.js"
51
+ import {
52
+ editorCommand,
53
+ launchDetached,
54
+ sshCommand,
55
+ terminalCommand,
56
+ type LaunchCommand,
57
+ } from "../platform/launch.js"
44
58
  import { ProcessManager } from "../processes/ProcessManager.js"
45
59
  import { DockerService } from "../services/docker.js"
46
60
  import { detectNodeServices, PortService } from "../services/node.js"
47
61
  import { OllamaService } from "../services/ollama.js"
48
62
  import { PostgresService } from "../services/postgres.js"
49
63
  import { RedisService } from "../services/redis.js"
64
+ import { RemoteMachineService, TailscaleService } from "../services/remote.js"
50
65
  import { ServiceManager } from "../services/ServiceManager.js"
51
- import { SystemMonitor } from "../system/SystemMonitor.js"
66
+ import { SystemMonitor, type ToolVersions } from "../system/SystemMonitor.js"
52
67
  import { errorMessage, toDevccError } from "../utils/errors.js"
53
68
  import { createLogger, type Logger } from "../utils/logger.js"
54
69
  import { detectPackageManager, readPackageJson, readScripts, runScriptArgs } from "../utils/packageManager.js"
55
70
  import { fuzzyFilter } from "../utils/fuzzy.js"
56
- import { findProjectRoot } from "../utils/paths.js"
71
+ import { findProjectRoot, tildify } from "../utils/paths.js"
72
+ import {
73
+ isVerifyIssue,
74
+ resolveVerifySteps,
75
+ runVerification,
76
+ summariseVerify,
77
+ type StepExecutor,
78
+ type VerifyStep,
79
+ } from "../verify/verify.js"
57
80
  import { DependenciesView } from "../views/DependenciesView.js"
58
81
  import { GitView } from "../views/GitView.js"
59
82
  import { LogsView } from "../views/LogsView.js"
@@ -63,8 +86,10 @@ import { ScriptsView, scriptProcessId } from "../views/ScriptsView.js"
63
86
  import { ServicesView } from "../views/ServicesView.js"
64
87
  import { SettingsView } from "../views/SettingsView.js"
65
88
  import { SystemView } from "../views/SystemView.js"
89
+ import { WorktreesView, selectedWorktreeEntry } from "../views/WorktreesView.js"
66
90
  import type { View, ViewDeps } from "../views/View.js"
67
91
  import { createBuiltinCommands, createConfigCommands, createScriptCommands } from "./builtinCommands.js"
92
+ import { createControlPlaneCommands } from "./controlPlaneCommands.js"
68
93
  import { HELP_SECTIONS, VIEW_SHORTCUTS } from "./keymap.js"
69
94
  import { theme } from "./theme.js"
70
95
 
@@ -101,7 +126,17 @@ interface ProjectSession {
101
126
  git: GitService
102
127
  system: SystemMonitor
103
128
  deps: DependencyService
129
+ agents: AgentManager
130
+ environment: EnvironmentService
104
131
  project: ProjectState
132
+ /** Verification steps resolved from config, and the latest run. */
133
+ verifySteps: VerifyStep[]
134
+ verify: VerifyState
135
+ /** Config problems that make verification untrustworthy; running is refused while any exist. */
136
+ verifyIssues: string[]
137
+ verifyAbort: AbortController | null
138
+ /** The agent session proposing a .devcc.ts, if one is running. */
139
+ setupAgentSessionId: string | null
105
140
  scripts: { name: string; command: string }[]
106
141
  /** Script runs belong to the project they ran in, not to the store. */
107
142
  scriptRuns: Record<string, ScriptRun>
@@ -151,6 +186,12 @@ export class App {
151
186
  private get deps(): DependencyService {
152
187
  return this.session.deps
153
188
  }
189
+ private get agents(): AgentManager {
190
+ return this.session.agents
191
+ }
192
+ private get environment(): EnvironmentService {
193
+ return this.session.environment
194
+ }
154
195
  private get config(): ResolvedConfig {
155
196
  return this.session.config
156
197
  }
@@ -242,21 +283,36 @@ export class App {
242
283
  logger: logger.child("services"),
243
284
  intervalMs: config.refresh.services,
244
285
  })
286
+ const tailscale = new TailscaleService({
287
+ machines: config.remoteMachines,
288
+ logger: logger.child("tailscale"),
289
+ })
245
290
  services.registerAll([
246
291
  new DockerService({ rootDir }),
247
292
  new PostgresService(),
248
293
  new RedisService(),
249
294
  new OllamaService(),
250
295
  ...detectNodeServices(packageJson),
251
- ...config.services.map(
252
- (entry) =>
253
- new PortService({
254
- id: entry.id,
255
- name: entry.name,
256
- port: entry.port,
257
- host: entry.host,
258
- healthPath: entry.healthPath,
259
- category: entry.category,
296
+ ...config.services.map((entry) =>
297
+ entry.type === "ollama"
298
+ ? new OllamaService({ id: entry.id, name: entry.name, baseUrl: entry.baseUrl })
299
+ : new PortService({
300
+ id: entry.id,
301
+ name: entry.name,
302
+ port: entry.port,
303
+ host: entry.host,
304
+ healthPath: entry.healthPath,
305
+ category: entry.category,
306
+ }),
307
+ ),
308
+ tailscale,
309
+ ...config.remoteMachines.map(
310
+ (machine) =>
311
+ new RemoteMachineService({
312
+ machine,
313
+ tailscale: () => tailscale.getLastStatus(),
314
+ service: (id) => services.get(id),
315
+ logger: logger.child("remote"),
260
316
  }),
261
317
  ),
262
318
  ])
@@ -267,13 +323,25 @@ export class App {
267
323
  manager: packageManager.manager,
268
324
  logger: logger.child("deps"),
269
325
  })
326
+ const agents = new AgentManager({
327
+ processes,
328
+ adapters: createAgentAdapters(config.agents),
329
+ logger: logger.child("agents"),
330
+ })
331
+ const environment = new EnvironmentService({ rootDir, logger: logger.child("environment") })
332
+ const scripts = readScripts(packageJson)
333
+ const resolvedVerify = resolveVerifySteps(config.verify, {
334
+ commands: config.commands,
335
+ scripts,
336
+ packageManager: packageManager.manager,
337
+ })
270
338
 
271
339
  const project: ProjectState = {
272
340
  name: config.name ?? (typeof packageJson?.name === "string" ? packageJson.name : basename(rootDir)),
273
341
  rootDir,
274
342
  packageManager,
275
343
  configPath: loaded.filePath,
276
- configIssues: loaded.issues,
344
+ configIssues: [...loaded.issues, ...resolvedVerify.issues],
277
345
  host: system.getHostInfo(),
278
346
  tools: { git: null, docker: null, packageManager: null },
279
347
  }
@@ -291,8 +359,15 @@ export class App {
291
359
  git,
292
360
  system,
293
361
  deps,
362
+ agents,
363
+ environment,
294
364
  project,
295
- scripts: readScripts(packageJson),
365
+ verifySteps: resolvedVerify.steps,
366
+ verify: { configured: resolvedVerify.steps.length, running: false, steps: [] },
367
+ verifyIssues: [...loaded.issues.filter(isVerifyIssue), ...resolvedVerify.issues],
368
+ verifyAbort: null,
369
+ setupAgentSessionId: null,
370
+ scripts,
296
371
  scriptRuns: {},
297
372
  wiring: new Subscriptions(),
298
373
  timers: [],
@@ -307,12 +382,21 @@ export class App {
307
382
  this.commands.registerAll(
308
383
  createConfigCommands(this.config.commands, (entry) => this.runConfigCommand(entry)),
309
384
  )
385
+ this.commands.registerAll(
386
+ createControlPlaneCommands({
387
+ agents: this.session.agents.list(),
388
+ machines: this.config.remoteMachines,
389
+ }),
390
+ )
310
391
  }
311
392
 
312
393
  private reportConfigIssues(): void {
313
394
  for (const issue of this.session.project.configIssues) {
314
395
  this.pushNotification("warn", `Config: ${issue}`)
315
396
  }
397
+ if (!this.session.project.configPath) {
398
+ this.pushNotification("info", "No .devcc.ts yet: press / and run “Project: set up .devcc.ts”")
399
+ }
316
400
  }
317
401
 
318
402
  // ---------------------------------------------------------------------
@@ -366,6 +450,7 @@ export class App {
366
450
  new ServicesView(deps),
367
451
  new ScriptsView(deps),
368
452
  new GitView(deps),
453
+ new WorktreesView(deps),
369
454
  new LogsView(deps),
370
455
  new DependenciesView(deps),
371
456
  new SystemView(deps),
@@ -429,13 +514,13 @@ export class App {
429
514
  */
430
515
  private wireSession(session: ProjectSession): void {
431
516
  const { store } = this
432
- const { wiring, processes, services, git, system, logs, deps } = session
517
+ const { wiring, processes, services, git, system, logs, deps, environment } = session
433
518
  const active = () => this.session === session
434
519
  const notify = (level: NotificationLevel, message: string, detail?: string) =>
435
520
  this.pushNotification(level, active() ? message : `[${session.project.name}] ${message}`, detail)
436
521
 
437
522
  const syncProcesses = () => {
438
- if (active()) store.patch({ processes: processes.list() })
523
+ if (active()) store.patch({ processes: processes.list(), agents: session.agents.sessions() })
439
524
  this.refreshSidebarProjects()
440
525
  }
441
526
  processes.on("process:changed", syncProcesses)
@@ -443,6 +528,7 @@ export class App {
443
528
  processes.on("process:exit", (process) => {
444
529
  this.finishScriptRun(session, process.id, process.exitCode ?? 0)
445
530
  syncProcesses()
531
+ if (process.id === session.setupAgentSessionId) void this.finishConfigSetup(session, process)
446
532
  })
447
533
  processes.on("process:error", (error) => notify("error", error.message, error.detail))
448
534
  processes.on("process:retry", (managed, delayMs) => {
@@ -473,11 +559,16 @@ export class App {
473
559
  system.on("system:metrics", (metrics) => {
474
560
  if (active()) store.patch({ system: metrics })
475
561
  })
476
- system.on("system:tools", (tools) => {
562
+ wiring.add(() => system.removeAllListeners())
563
+
564
+ environment.on("environment:changed", (snapshot) => {
565
+ const tools = toolVersionsFrom(snapshot, session.project.packageManager.manager)
477
566
  session.project = { ...session.project, tools }
478
- if (active()) store.setState((state) => ({ ...state, project: { ...state.project, tools } }))
567
+ if (active()) {
568
+ store.setState((state) => ({ ...state, environment: snapshot, project: { ...state.project, tools } }))
569
+ }
479
570
  })
480
- wiring.add(() => system.removeAllListeners())
571
+ wiring.add(() => environment.removeAllListeners())
481
572
 
482
573
  logs.on("log:sources", (sources) => {
483
574
  if (!active()) return
@@ -540,6 +631,9 @@ export class App {
540
631
  this.subscriptions.add(store.watch((state) => state.services, markDirty, shallowArrayEqual))
541
632
  this.subscriptions.add(store.watch((state) => state.git, markDirty))
542
633
  this.subscriptions.add(store.watch((state) => state.dependencies, markDirty))
634
+ this.subscriptions.add(store.watch((state) => state.agents, markDirty, shallowArrayEqual))
635
+ this.subscriptions.add(store.watch((state) => state.environment, markDirty))
636
+ this.subscriptions.add(store.watch((state) => state.verify, markDirty))
543
637
  this.subscriptions.add(store.watch((state) => state.projects, markDirty, shallowArrayEqual))
544
638
  this.subscriptions.add(store.watch((state) => state.system, markDirty))
545
639
  this.subscriptions.add(store.watch((state) => state.scripts, markDirty))
@@ -557,6 +651,7 @@ export class App {
557
651
  processes: this.processes.list(),
558
652
  services: this.services.list(),
559
653
  logSources: this.logs.sources(),
654
+ verify: this.session.verify,
560
655
  })
561
656
  store.setState((state) => ({
562
657
  ...state,
@@ -596,7 +691,8 @@ export class App {
596
691
 
597
692
  void session.git.refresh()
598
693
  void session.services.detectAll().then(() => session.services.start())
599
- void session.system.refreshTools(session.project.packageManager.manager)
694
+ // Tool versions change rarely: detect once per project, refresh on request.
695
+ if (!session.environment.getSnapshot().checkedAt) void session.environment.refresh()
600
696
  if (!session.processes.list().some((entry) => entry.startedAt)) {
601
697
  // Autostart only the first time a project is opened, never on reactivation.
602
698
  void session.processes.startAutoStart()
@@ -759,6 +855,11 @@ export class App {
759
855
  void this.commands.run("app.refresh", this.context)
760
856
  return
761
857
  }
858
+ if (key.shift && key.name === "v") {
859
+ key.preventDefault()
860
+ void this.runCommand("verify.run")
861
+ return
862
+ }
762
863
 
763
864
  const state = this.store.getState()
764
865
 
@@ -1035,6 +1136,8 @@ export class App {
1035
1136
  logs: this.logs,
1036
1137
  system: this.system,
1037
1138
  deps: this.deps,
1139
+ agents: this.agents,
1140
+ environment: this.environment,
1038
1141
  navigate: (view) => updateUi(this.store, { view }),
1039
1142
  notify: (level, message, detail) => this.pushNotification(level, message, detail),
1040
1143
  confirm: (title, message) => this.askConfirm(title, message),
@@ -1046,6 +1149,17 @@ export class App {
1046
1149
  openProjects: () => this.openProjects(),
1047
1150
  switchProject: (path) => this.openProject(path),
1048
1151
  closeProject: (path) => this.closeProject(path),
1152
+ selectedWorktree: () => this.selectedWorktree(),
1153
+ createWorktree: () => this.createWorktree(),
1154
+ removeWorktree: (worktree) => this.removeWorktree(worktree),
1155
+ launchAgent: (agentId, worktree) => this.launchAgent(agentId, worktree),
1156
+ openInEditor: (path) => this.launch(() => editorCommand(path, this.config.editor), path),
1157
+ openInTerminal: (path) => this.launch(() => terminalCommand(path, this.config.terminal), path),
1158
+ runVerify: () => this.runVerify(),
1159
+ remoteMachines: () => this.config.remoteMachines,
1160
+ sshTo: (machineId) => this.sshTo(machineId),
1161
+ setUpConfig: (how) => this.setUpConfig(how),
1162
+ reloadConfig: () => this.reloadConfig(),
1049
1163
  quit: () => void this.quit(),
1050
1164
  }
1051
1165
 
@@ -1058,6 +1172,8 @@ export class App {
1058
1172
  logs: { get: () => this.logs, enumerable: true },
1059
1173
  system: { get: () => this.system, enumerable: true },
1060
1174
  deps: { get: () => this.deps, enumerable: true },
1175
+ agents: { get: () => this.agents, enumerable: true },
1176
+ environment: { get: () => this.environment, enumerable: true },
1061
1177
  })
1062
1178
 
1063
1179
  return context
@@ -1262,6 +1378,9 @@ export class App {
1262
1378
  scripts: { available: session.scripts, runs: session.scriptRuns },
1263
1379
  git: session.git.getSnapshot(),
1264
1380
  dependencies: session.deps.getSnapshot(),
1381
+ agents: session.agents.sessions(),
1382
+ environment: session.environment.getSnapshot(),
1383
+ verify: session.verify,
1265
1384
  system: session.system.getMetrics(),
1266
1385
  logSources: session.logs.sources(),
1267
1386
  projects: [],
@@ -1349,6 +1468,379 @@ export class App {
1349
1468
  )
1350
1469
  }
1351
1470
 
1471
+ // ---------------------------------------------------------------------
1472
+ // Worktrees, agents, verification, remote
1473
+ // ---------------------------------------------------------------------
1474
+
1475
+ private selectedWorktree(): GitWorktree | undefined {
1476
+ const state = this.store.getState()
1477
+ if (state.ui.view === "worktrees") {
1478
+ const entry = selectedWorktreeEntry(state)
1479
+ if (entry?.worktree) return entry.worktree
1480
+ }
1481
+ return state.git.worktrees.find((worktree) => worktree.isCurrent)
1482
+ }
1483
+
1484
+ private async createWorktree(): Promise<void> {
1485
+ const snapshot = this.git.getSnapshot()
1486
+ if (!snapshot.isRepository) {
1487
+ this.pushNotification("warn", "Not a git repository")
1488
+ return
1489
+ }
1490
+ const main = snapshot.worktrees.find((worktree) => worktree.isMain)
1491
+ if (!main) {
1492
+ this.pushNotification("warn", "Could not read this repository's worktrees yet")
1493
+ return
1494
+ }
1495
+
1496
+ const branch = (
1497
+ await this.askPrompt("New worktree: branch (created if it does not exist)", "feature/name")
1498
+ )?.trim()
1499
+ if (!branch) return
1500
+ const current = snapshot.status.branch ?? "HEAD"
1501
+ const base = await this.askPrompt(`Base for ${branch} (empty: ${current})`, current)
1502
+ if (base === null) return
1503
+ const suggested = defaultWorktreePath(main.path, branch, this.config.worktrees?.directory)
1504
+ const path = await this.askPrompt(`Directory (empty: ${tildify(suggested)})`, tildify(suggested))
1505
+ if (path === null) return
1506
+
1507
+ const target = path.trim() === "" ? suggested : resolve(main.path, expandHome(path.trim()))
1508
+ try {
1509
+ this.footer.setStatus(`Creating worktree ${branch}…`)
1510
+ await this.git.createWorktree({ branch, base: base.trim() || undefined, path: target })
1511
+ this.pushNotification("success", `Created worktree ${branch} at ${tildify(target)}`)
1512
+ } catch (error) {
1513
+ const devccError = toDevccError(error, "git")
1514
+ this.pushNotification("error", devccError.message, devccError.detail)
1515
+ }
1516
+ }
1517
+
1518
+ private async removeWorktree(worktree: GitWorktree): Promise<void> {
1519
+ const busy = [
1520
+ ...this.agents.busyPaths(),
1521
+ ...this.processes
1522
+ .list()
1523
+ .filter((entry) => isActive(entry.status))
1524
+ .map((entry) => resolve(this.session.rootDir, entry.cwd ?? ".")),
1525
+ // A worktree open as a project in devcc is in use too.
1526
+ ...[...this.sessions.keys()].filter((path) => path !== this.session.rootDir),
1527
+ ]
1528
+ const blocker = removalBlocker(worktree, busy)
1529
+ if (blocker) {
1530
+ this.pushNotification("warn", blocker)
1531
+ return
1532
+ }
1533
+
1534
+ const confirmed = await this.askConfirm(
1535
+ `Remove worktree ${worktreeLabel(worktree)}?`,
1536
+ `Deletes ${tildify(worktree.path)}. The branch is kept. git refuses if there are uncommitted or untracked changes; devcc never forces it.`,
1537
+ )
1538
+ if (!confirmed) return
1539
+
1540
+ try {
1541
+ await this.git.removeWorktree(worktree.path, busy)
1542
+ this.pushNotification("success", `Removed worktree ${worktreeLabel(worktree)}`)
1543
+ } catch (error) {
1544
+ const devccError = toDevccError(error, "git")
1545
+ this.pushNotification("error", devccError.message, devccError.detail)
1546
+ }
1547
+ }
1548
+
1549
+ private async launchAgent(agentId: string, worktree?: GitWorktree): Promise<void> {
1550
+ const adapter = this.agents.get(agentId)
1551
+ if (!adapter) {
1552
+ this.pushNotification("warn", `Unknown agent "${agentId}"`)
1553
+ return
1554
+ }
1555
+ const target = worktree ?? this.selectedWorktree()
1556
+ const cwd = target?.path ?? this.session.rootDir
1557
+ const where = target ? worktreeLabel(target) : basename(cwd)
1558
+
1559
+ const task = await this.askPrompt(
1560
+ `Task for ${adapter.name} in ${where}`,
1561
+ "Describe what the agent should do",
1562
+ )
1563
+ if (task === null || task.trim() === "") return
1564
+
1565
+ try {
1566
+ const session = await this.agents.launch(agentId, { cwd, prompt: task, branch: target?.branch })
1567
+ updateUi(this.store, { logSource: session.id })
1568
+ this.pushNotification("success", `${adapter.name} started in ${where}`)
1569
+ } catch (error) {
1570
+ const devccError = toDevccError(error, "process")
1571
+ this.pushNotification("error", devccError.message, devccError.detail)
1572
+ }
1573
+ }
1574
+
1575
+ /** Build and start an external launch; a missing configuration becomes a notification. */
1576
+ private async launch(build: () => LaunchCommand, cwd: string): Promise<void> {
1577
+ try {
1578
+ const command = build()
1579
+ await launchDetached(command, cwd)
1580
+ this.logger.log("debug", "launch", "opened", { command: command.command, cwd })
1581
+ } catch (error) {
1582
+ const devccError = toDevccError(error, "command")
1583
+ this.pushNotification("error", devccError.message, devccError.detail)
1584
+ }
1585
+ }
1586
+
1587
+ private async sshTo(machineId: string): Promise<void> {
1588
+ const machine = this.config.remoteMachines.find((entry) => entry.id === machineId)
1589
+ if (!machine) {
1590
+ this.pushNotification("warn", `Unknown remote machine "${machineId}"`)
1591
+ return
1592
+ }
1593
+ await this.launch(() => sshCommand(machine, this.config.terminal), this.session.rootDir)
1594
+ }
1595
+
1596
+ /**
1597
+ * Run verification through the ProcessManager: each step is a managed task,
1598
+ * so its output streams into Logs and quitting stops it like anything else.
1599
+ */
1600
+ private async runVerify(): Promise<void> {
1601
+ const session = this.session
1602
+ if (session.verify.running) {
1603
+ this.pushNotification("warn", "Verification is already running")
1604
+ return
1605
+ }
1606
+ if (session.verifyIssues.length > 0) {
1607
+ this.pushNotification(
1608
+ "error",
1609
+ `Verify refused: ${session.verifyIssues[0] ?? "invalid verify config"}`,
1610
+ session.verifyIssues.join("\n"),
1611
+ )
1612
+ return
1613
+ }
1614
+ if (session.verifySteps.length === 0) {
1615
+ this.pushNotification(
1616
+ "warn",
1617
+ "No verification steps configured",
1618
+ 'Add verify: ["lint", "test"] to .devcc.ts',
1619
+ )
1620
+ return
1621
+ }
1622
+
1623
+ const controller = new AbortController()
1624
+ session.verifyAbort = controller
1625
+ const publish = (verify: VerifyState) => {
1626
+ session.verify = verify
1627
+ if (session === this.session) this.store.patch({ verify })
1628
+ }
1629
+ const startedAt = new Date()
1630
+ publish({ configured: session.verifySteps.length, running: true, steps: [], startedAt })
1631
+ this.pushNotification("info", `Verifying: ${session.verifySteps.map((step) => step.name).join(", ")}`)
1632
+
1633
+ const report = await runVerification(session.verifySteps, this.processStepExecutor(session), {
1634
+ signal: controller.signal,
1635
+ onProgress: (steps) => publish({ ...session.verify, steps: [...steps] }),
1636
+ })
1637
+
1638
+ session.verifyAbort = null
1639
+ publish({
1640
+ configured: session.verifySteps.length,
1641
+ running: false,
1642
+ steps: report.steps,
1643
+ startedAt,
1644
+ finishedAt: new Date(),
1645
+ ok: report.ok,
1646
+ })
1647
+ this.pushNotification(report.ok ? "success" : "error", `Verify: ${summariseVerify(report)}`)
1648
+ }
1649
+
1650
+ private processStepExecutor(session: ProjectSession): StepExecutor {
1651
+ return async (step, signal) => {
1652
+ const { processes } = session
1653
+ const id = `verify:${step.id}`
1654
+ if (processes.isRunning(id)) return { exitCode: null, error: "already running" }
1655
+
1656
+ processes.register({
1657
+ id,
1658
+ name: `verify · ${step.name}`,
1659
+ command: step.command,
1660
+ args: step.args,
1661
+ cwd: step.cwd,
1662
+ env: { NO_COLOR: "1" },
1663
+ kind: "task",
1664
+ })
1665
+
1666
+ let onChange: ((snapshot: ManagedProcess) => void) | undefined
1667
+ const finished = new Promise<ManagedProcess>((resolveFinished) => {
1668
+ let started = false
1669
+ onChange = (snapshot: ManagedProcess) => {
1670
+ if (snapshot.id !== id) return
1671
+ if (snapshot.status === "starting" || snapshot.status === "running") started = true
1672
+ else if (started && (snapshot.status === "stopped" || snapshot.status === "failed")) {
1673
+ resolveFinished(snapshot)
1674
+ }
1675
+ }
1676
+ processes.on("process:changed", onChange)
1677
+ })
1678
+ const onAbort = () => void processes.stop(id).catch(() => undefined)
1679
+ signal.addEventListener("abort", onAbort, { once: true })
1680
+
1681
+ try {
1682
+ if (session === this.session) updateUi(this.store, { logSource: id })
1683
+ await processes.start(id)
1684
+ const result = await finished
1685
+ return {
1686
+ exitCode: result.exitCode ?? null,
1687
+ error: signal.aborted
1688
+ ? "cancelled"
1689
+ : result.exitSignal
1690
+ ? `terminated by ${result.exitSignal}`
1691
+ : result.exitCode === undefined
1692
+ ? result.lastError
1693
+ : undefined,
1694
+ }
1695
+ } catch (error) {
1696
+ return { exitCode: null, error: errorMessage(error) }
1697
+ } finally {
1698
+ signal.removeEventListener("abort", onAbort)
1699
+ // Also runs when start() throws, so the listener never outlives the step.
1700
+ if (onChange) processes.off("process:changed", onChange)
1701
+ }
1702
+ }
1703
+ }
1704
+
1705
+ // ---------------------------------------------------------------------
1706
+ // Project setup (.devcc.ts)
1707
+ // ---------------------------------------------------------------------
1708
+
1709
+ private async setUpConfig(how: string): Promise<void> {
1710
+ const session = this.session
1711
+ const facts = detectProject(session.rootDir)
1712
+
1713
+ if (how === "starter") {
1714
+ if (facts.configPath) {
1715
+ const replace = await this.askConfirm(
1716
+ "Replace the existing config with a starter?",
1717
+ `${tildify(facts.configPath)} will be overwritten with what devcc detects. Consider setting it up with an agent instead, which keeps what is still right.`,
1718
+ )
1719
+ if (!replace) return
1720
+ }
1721
+ const target = join(session.rootDir, ".devcc.ts")
1722
+ writeFileSync(target, renderStarterConfig(facts), "utf8")
1723
+ this.pushNotification("success", `Created ${tildify(target)}`)
1724
+ await this.reloadConfig()
1725
+ return
1726
+ }
1727
+
1728
+ const adapter = this.agents.get(how)
1729
+ if (!adapter) {
1730
+ this.pushNotification("warn", `Unknown agent "${how}"`)
1731
+ return
1732
+ }
1733
+ if (session.setupAgentSessionId && session.processes.isRunning(session.setupAgentSessionId)) {
1734
+ this.pushNotification("warn", "An agent is already setting up .devcc.ts")
1735
+ return
1736
+ }
1737
+
1738
+ try {
1739
+ const agentSession = await this.agents.launch(how, {
1740
+ cwd: session.rootDir,
1741
+ prompt: buildSetupPrompt(facts, "print"),
1742
+ })
1743
+ session.setupAgentSessionId = agentSession.id
1744
+ updateUi(this.store, { logSource: agentSession.id })
1745
+ this.pushNotification(
1746
+ "info",
1747
+ `${adapter.name} is reading the project; devcc will offer its .devcc.ts when it finishes`,
1748
+ )
1749
+ } catch (error) {
1750
+ const devccError = toDevccError(error, "process")
1751
+ this.pushNotification("error", devccError.message, devccError.detail)
1752
+ }
1753
+ }
1754
+
1755
+ /**
1756
+ * The setup agent exited: take the config it printed, validate it from a
1757
+ * scratch file under .devcc/, and write it only if the user agrees.
1758
+ */
1759
+ private async finishConfigSetup(session: ProjectSession, agent: ManagedProcess): Promise<void> {
1760
+ session.setupAgentSessionId = null
1761
+ const name = session.project.name
1762
+ if (agent.exitCode !== 0) {
1763
+ this.pushNotification(
1764
+ "error",
1765
+ `[${name}] Config setup agent exited with code ${agent.exitCode ?? "?"}; see its logs`,
1766
+ )
1767
+ return
1768
+ }
1769
+
1770
+ const output = (session.logs.get(agent.id)?.toArray() ?? [])
1771
+ .filter((entry) => entry.stream !== "system")
1772
+ .map((entry) => entry.text)
1773
+ .join("\n")
1774
+ const proposal = extractConfigFromOutput(output)
1775
+ if (!proposal) {
1776
+ this.pushNotification("warn", `[${name}] The agent did not print a .devcc.ts; see its logs`)
1777
+ return
1778
+ }
1779
+
1780
+ const scratchDir = join(session.rootDir, ".devcc")
1781
+ const proposedPath = join(scratchDir, "proposed.devcc.ts")
1782
+ mkdirSync(scratchDir, { recursive: true })
1783
+ writeFileSync(proposedPath, proposal, "utf8")
1784
+ const loaded = await loadConfigFile(proposedPath)
1785
+ const problems = configProblems(session.rootDir, loaded)
1786
+ const { config } = loaded
1787
+
1788
+ const current = session.project.configPath
1789
+ const target = current && !current.endsWith(".json") ? current : join(session.rootDir, ".devcc.ts")
1790
+ const summary = [
1791
+ `${config.processes.length} process(es), ${config.commands.length} command(s), verify: ${
1792
+ config.verify.map((entry) => (typeof entry === "string" ? entry : entry.id)).join(", ") || "none"
1793
+ }.`,
1794
+ problems.length > 0 ? `${problems.length} issue(s): ${problems[0] ?? ""}` : "It validates cleanly.",
1795
+ `Review it first in ${tildify(proposedPath)}.`,
1796
+ ].join(" ")
1797
+
1798
+ const write = await this.askConfirm(
1799
+ `Write ${current ? "the updated" : "the proposed"} ${tildify(target)}?`,
1800
+ summary,
1801
+ )
1802
+ if (!write) {
1803
+ this.pushNotification("info", `Kept the proposal at ${tildify(proposedPath)}`)
1804
+ return
1805
+ }
1806
+ writeFileSync(target, proposal, "utf8")
1807
+ this.pushNotification("success", `Wrote ${tildify(target)}`)
1808
+ if (session === this.session) await this.reloadConfig()
1809
+ else this.pushNotification("info", `Switch to ${name} and reload its config to use it`)
1810
+ }
1811
+
1812
+ /** Rebuild the active project's session so a changed config takes effect. */
1813
+ private async reloadConfig(): Promise<void> {
1814
+ const previous = this.session
1815
+ const running = previous.processes.list().filter((entry) => isActive(entry.status))
1816
+ if (running.length > 0) {
1817
+ const confirmed = await this.askConfirm(
1818
+ `Reload the config and stop ${running.length} running process(es)?`,
1819
+ `${running.map((entry) => entry.name).join(", ")} will be stopped; start them again afterwards.`,
1820
+ )
1821
+ if (!confirmed) return
1822
+ }
1823
+
1824
+ this.stopSessionBackground(previous)
1825
+ previous.verifyAbort?.abort()
1826
+ await previous.processes.stopAll()
1827
+ previous.wiring.dispose()
1828
+
1829
+ try {
1830
+ const next = await this.createSession(previous.rootDir)
1831
+ this.sessions.set(next.rootDir, next)
1832
+ this.wireSession(next)
1833
+ this.activateSession(next)
1834
+ this.reportConfigIssues()
1835
+ this.pushNotification(
1836
+ "success",
1837
+ `Reloaded ${next.project.configPath ? basename(next.project.configPath) : "config"}`,
1838
+ )
1839
+ } catch (error) {
1840
+ this.pushNotification("error", `Could not reload the config: ${errorMessage(error)}`)
1841
+ }
1842
+ }
1843
+
1352
1844
  // ---------------------------------------------------------------------
1353
1845
  // Dependencies
1354
1846
  // ---------------------------------------------------------------------
@@ -1404,6 +1896,7 @@ export class App {
1404
1896
  this.footer.setStatus("Shutting down…")
1405
1897
 
1406
1898
  this.stopBackground()
1899
+ for (const session of this.sessions.values()) session.verifyAbort?.abort()
1407
1900
 
1408
1901
  // Every open project, not just the visible one.
1409
1902
  await Promise.all(
@@ -1461,3 +1954,17 @@ export class App {
1461
1954
  return this.logs
1462
1955
  }
1463
1956
  }
1957
+
1958
+ /** The header and System view still read `project.tools`; derive it from environment detection. */
1959
+ function toolVersionsFrom(snapshot: EnvironmentSnapshot, packageManager: string): ToolVersions {
1960
+ const version = (id: string) => {
1961
+ const tool = snapshot.tools.find((entry) => entry.id === id)
1962
+ return tool?.installed ? (tool.version ?? "installed") : null
1963
+ }
1964
+ return { git: version("git"), docker: version("docker"), packageManager: version(packageManager) }
1965
+ }
1966
+
1967
+ function expandHome(path: string): string {
1968
+ const home = process.env.HOME
1969
+ return home && (path === "~" || path.startsWith("~/")) ? `${home}${path.slice(1)}` : path
1970
+ }