@doriandev/devcc 0.1.2 → 0.2.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 (136) hide show
  1. package/README.md +556 -386
  2. package/bin/devcc.mjs +50 -23
  3. package/dist/devcc/agents/agents.d.ts +112 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -0
  5. package/dist/devcc/agents/agents.js +179 -0
  6. package/dist/devcc/agents/agents.js.map +1 -0
  7. package/dist/devcc/app/App.d.ts +15 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +326 -17
  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 +179 -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 +24 -3
  17. package/dist/devcc/app/keymap.js.map +1 -1
  18. package/dist/devcc/automation.d.ts +31 -0
  19. package/dist/devcc/automation.d.ts.map +1 -0
  20. package/dist/devcc/automation.js +202 -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 +46 -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 +25 -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 +69 -3
  35. package/dist/devcc/core/config.d.ts.map +1 -1
  36. package/dist/devcc/core/config.js +185 -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 +13 -3
  63. package/dist/devcc/index.d.ts.map +1 -1
  64. package/dist/devcc/index.js +11 -1
  65. package/dist/devcc/index.js.map +1 -1
  66. package/dist/devcc/platform/launch.d.ts +30 -0
  67. package/dist/devcc/platform/launch.d.ts.map +1 -0
  68. package/dist/devcc/platform/launch.js +93 -0
  69. package/dist/devcc/platform/launch.js.map +1 -0
  70. package/dist/devcc/services/ServiceManager.d.ts +10 -2
  71. package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
  72. package/dist/devcc/services/ServiceManager.js +18 -4
  73. package/dist/devcc/services/ServiceManager.js.map +1 -1
  74. package/dist/devcc/services/ollama.d.ts +64 -5
  75. package/dist/devcc/services/ollama.d.ts.map +1 -1
  76. package/dist/devcc/services/ollama.js +118 -17
  77. package/dist/devcc/services/ollama.js.map +1 -1
  78. package/dist/devcc/services/remote.d.ts +90 -0
  79. package/dist/devcc/services/remote.d.ts.map +1 -0
  80. package/dist/devcc/services/remote.js +233 -0
  81. package/dist/devcc/services/remote.js.map +1 -0
  82. package/dist/devcc/services/types.d.ts +8 -1
  83. package/dist/devcc/services/types.d.ts.map +1 -1
  84. package/dist/devcc/services/types.js.map +1 -1
  85. package/dist/devcc/utils/exec.d.ts +6 -0
  86. package/dist/devcc/utils/exec.d.ts.map +1 -1
  87. package/dist/devcc/utils/exec.js +12 -2
  88. package/dist/devcc/utils/exec.js.map +1 -1
  89. package/dist/devcc/verify/verify.d.ts +107 -0
  90. package/dist/devcc/verify/verify.d.ts.map +1 -0
  91. package/dist/devcc/verify/verify.js +203 -0
  92. package/dist/devcc/verify/verify.js.map +1 -0
  93. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  94. package/dist/devcc/views/OverviewView.js +55 -1
  95. package/dist/devcc/views/OverviewView.js.map +1 -1
  96. package/dist/devcc/views/ServicesView.d.ts +2 -0
  97. package/dist/devcc/views/ServicesView.d.ts.map +1 -1
  98. package/dist/devcc/views/ServicesView.js +15 -0
  99. package/dist/devcc/views/ServicesView.js.map +1 -1
  100. package/dist/devcc/views/SystemView.d.ts +10 -1
  101. package/dist/devcc/views/SystemView.d.ts.map +1 -1
  102. package/dist/devcc/views/SystemView.js +127 -19
  103. package/dist/devcc/views/SystemView.js.map +1 -1
  104. package/dist/devcc/views/WorktreesView.d.ts +48 -0
  105. package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
  106. package/dist/devcc/views/WorktreesView.js +235 -0
  107. package/dist/devcc/views/WorktreesView.js.map +1 -0
  108. package/package.json +1 -1
  109. package/src/devcc/agents/agents.ts +282 -0
  110. package/src/devcc/app/App.ts +376 -20
  111. package/src/devcc/app/controlPlaneCommands.ts +197 -0
  112. package/src/devcc/app/keymap.ts +24 -3
  113. package/src/devcc/automation.ts +242 -0
  114. package/src/devcc/cli.ts +45 -3
  115. package/src/devcc/components/Sidebar.ts +1 -0
  116. package/src/devcc/components/primitives.ts +3 -0
  117. package/src/devcc/core/commands.ts +26 -0
  118. package/src/devcc/core/config.ts +271 -11
  119. package/src/devcc/core/state.ts +25 -0
  120. package/src/devcc/environment/EnvironmentService.ts +215 -0
  121. package/src/devcc/environment/integrations.ts +133 -0
  122. package/src/devcc/environment/tools.ts +151 -0
  123. package/src/devcc/git/GitService.ts +104 -2
  124. package/src/devcc/git/worktrees.ts +256 -0
  125. package/src/devcc/index.ts +57 -2
  126. package/src/devcc/platform/launch.ts +125 -0
  127. package/src/devcc/services/ServiceManager.ts +15 -3
  128. package/src/devcc/services/ollama.ts +162 -19
  129. package/src/devcc/services/remote.ts +306 -0
  130. package/src/devcc/services/types.ts +9 -1
  131. package/src/devcc/utils/exec.ts +26 -2
  132. package/src/devcc/verify/verify.ts +316 -0
  133. package/src/devcc/views/OverviewView.ts +76 -1
  134. package/src/devcc/views/ServicesView.ts +17 -0
  135. package/src/devcc/views/SystemView.ts +144 -22
  136. package/src/devcc/views/WorktreesView.ts +270 -0
@@ -1,6 +1,7 @@
1
1
  import { BoxRenderable, createCliRenderer, type CliRenderer, type KeyEvent } from "@opentui/core"
2
2
  import { existsSync } from "node:fs"
3
3
  import { basename, join, resolve } from "node:path"
4
+ import { AgentManager, createAgentAdapters } from "../agents/agents.js"
4
5
  import { ConfirmDialog, PromptDialog } from "../components/ConfirmDialog.js"
5
6
  import { Footer, type KeyHint } from "../components/Footer.js"
6
7
  import { Header } from "../components/Header.js"
@@ -11,6 +12,7 @@ import { CommandPalette } from "../components/CommandPalette.js"
11
12
  import { ProjectPicker } from "../components/ProjectPicker.js"
12
13
  import { CommandRegistry, type CommandContext } from "../core/commands.js"
13
14
  import { loadConfig, type CommandConfigEntry, type ResolvedConfig } from "../core/config.js"
15
+ import { EnvironmentService, type EnvironmentSnapshot } from "../environment/EnvironmentService.js"
14
16
  import { Subscriptions } from "../core/events.js"
15
17
  import {
16
18
  createInitialSelection,
@@ -26,10 +28,12 @@ import {
26
28
  type NotificationLevel,
27
29
  type ProjectState,
28
30
  type ScriptRun,
31
+ type VerifyState,
29
32
  type ViewId,
30
33
  } from "../core/state.js"
31
34
  import { DependencyService, updateArgs } from "../deps/DependencyService.js"
32
35
  import { GitService } from "../git/GitService.js"
36
+ import { defaultWorktreePath, removalBlocker, worktreeLabel, type GitWorktree } from "../git/worktrees.js"
33
37
  import {
34
38
  buildProjectList,
35
39
  defaultSearchDirs,
@@ -40,20 +44,36 @@ import {
40
44
  } from "../projects/ProjectRegistry.js"
41
45
  import { LogStore, MAX_LOG_LINES } from "../logs/LogBuffer.js"
42
46
  import { ALL_SOURCES } from "../logs/merge.js"
43
- import { isActive } from "../processes/ManagedProcess.js"
47
+ import { isActive, type ManagedProcess } from "../processes/ManagedProcess.js"
48
+ import {
49
+ editorCommand,
50
+ launchDetached,
51
+ sshCommand,
52
+ terminalCommand,
53
+ type LaunchCommand,
54
+ } from "../platform/launch.js"
44
55
  import { ProcessManager } from "../processes/ProcessManager.js"
45
56
  import { DockerService } from "../services/docker.js"
46
57
  import { detectNodeServices, PortService } from "../services/node.js"
47
58
  import { OllamaService } from "../services/ollama.js"
48
59
  import { PostgresService } from "../services/postgres.js"
49
60
  import { RedisService } from "../services/redis.js"
61
+ import { RemoteMachineService, TailscaleService } from "../services/remote.js"
50
62
  import { ServiceManager } from "../services/ServiceManager.js"
51
- import { SystemMonitor } from "../system/SystemMonitor.js"
63
+ import { SystemMonitor, type ToolVersions } from "../system/SystemMonitor.js"
52
64
  import { errorMessage, toDevccError } from "../utils/errors.js"
53
65
  import { createLogger, type Logger } from "../utils/logger.js"
54
66
  import { detectPackageManager, readPackageJson, readScripts, runScriptArgs } from "../utils/packageManager.js"
55
67
  import { fuzzyFilter } from "../utils/fuzzy.js"
56
- import { findProjectRoot } from "../utils/paths.js"
68
+ import { findProjectRoot, tildify } from "../utils/paths.js"
69
+ import {
70
+ isVerifyIssue,
71
+ resolveVerifySteps,
72
+ runVerification,
73
+ summariseVerify,
74
+ type StepExecutor,
75
+ type VerifyStep,
76
+ } from "../verify/verify.js"
57
77
  import { DependenciesView } from "../views/DependenciesView.js"
58
78
  import { GitView } from "../views/GitView.js"
59
79
  import { LogsView } from "../views/LogsView.js"
@@ -63,8 +83,10 @@ import { ScriptsView, scriptProcessId } from "../views/ScriptsView.js"
63
83
  import { ServicesView } from "../views/ServicesView.js"
64
84
  import { SettingsView } from "../views/SettingsView.js"
65
85
  import { SystemView } from "../views/SystemView.js"
86
+ import { WorktreesView, selectedWorktreeEntry } from "../views/WorktreesView.js"
66
87
  import type { View, ViewDeps } from "../views/View.js"
67
88
  import { createBuiltinCommands, createConfigCommands, createScriptCommands } from "./builtinCommands.js"
89
+ import { createControlPlaneCommands } from "./controlPlaneCommands.js"
68
90
  import { HELP_SECTIONS, VIEW_SHORTCUTS } from "./keymap.js"
69
91
  import { theme } from "./theme.js"
70
92
 
@@ -101,7 +123,15 @@ interface ProjectSession {
101
123
  git: GitService
102
124
  system: SystemMonitor
103
125
  deps: DependencyService
126
+ agents: AgentManager
127
+ environment: EnvironmentService
104
128
  project: ProjectState
129
+ /** Verification steps resolved from config, and the latest run. */
130
+ verifySteps: VerifyStep[]
131
+ verify: VerifyState
132
+ /** Config problems that make verification untrustworthy; running is refused while any exist. */
133
+ verifyIssues: string[]
134
+ verifyAbort: AbortController | null
105
135
  scripts: { name: string; command: string }[]
106
136
  /** Script runs belong to the project they ran in, not to the store. */
107
137
  scriptRuns: Record<string, ScriptRun>
@@ -151,6 +181,12 @@ export class App {
151
181
  private get deps(): DependencyService {
152
182
  return this.session.deps
153
183
  }
184
+ private get agents(): AgentManager {
185
+ return this.session.agents
186
+ }
187
+ private get environment(): EnvironmentService {
188
+ return this.session.environment
189
+ }
154
190
  private get config(): ResolvedConfig {
155
191
  return this.session.config
156
192
  }
@@ -242,21 +278,36 @@ export class App {
242
278
  logger: logger.child("services"),
243
279
  intervalMs: config.refresh.services,
244
280
  })
281
+ const tailscale = new TailscaleService({
282
+ machines: config.remoteMachines,
283
+ logger: logger.child("tailscale"),
284
+ })
245
285
  services.registerAll([
246
286
  new DockerService({ rootDir }),
247
287
  new PostgresService(),
248
288
  new RedisService(),
249
289
  new OllamaService(),
250
290
  ...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,
291
+ ...config.services.map((entry) =>
292
+ entry.type === "ollama"
293
+ ? new OllamaService({ id: entry.id, name: entry.name, baseUrl: entry.baseUrl })
294
+ : new PortService({
295
+ id: entry.id,
296
+ name: entry.name,
297
+ port: entry.port,
298
+ host: entry.host,
299
+ healthPath: entry.healthPath,
300
+ category: entry.category,
301
+ }),
302
+ ),
303
+ tailscale,
304
+ ...config.remoteMachines.map(
305
+ (machine) =>
306
+ new RemoteMachineService({
307
+ machine,
308
+ tailscale: () => tailscale.getLastStatus(),
309
+ service: (id) => services.get(id),
310
+ logger: logger.child("remote"),
260
311
  }),
261
312
  ),
262
313
  ])
@@ -267,13 +318,25 @@ export class App {
267
318
  manager: packageManager.manager,
268
319
  logger: logger.child("deps"),
269
320
  })
321
+ const agents = new AgentManager({
322
+ processes,
323
+ adapters: createAgentAdapters(config.agents),
324
+ logger: logger.child("agents"),
325
+ })
326
+ const environment = new EnvironmentService({ rootDir, logger: logger.child("environment") })
327
+ const scripts = readScripts(packageJson)
328
+ const resolvedVerify = resolveVerifySteps(config.verify, {
329
+ commands: config.commands,
330
+ scripts,
331
+ packageManager: packageManager.manager,
332
+ })
270
333
 
271
334
  const project: ProjectState = {
272
335
  name: config.name ?? (typeof packageJson?.name === "string" ? packageJson.name : basename(rootDir)),
273
336
  rootDir,
274
337
  packageManager,
275
338
  configPath: loaded.filePath,
276
- configIssues: loaded.issues,
339
+ configIssues: [...loaded.issues, ...resolvedVerify.issues],
277
340
  host: system.getHostInfo(),
278
341
  tools: { git: null, docker: null, packageManager: null },
279
342
  }
@@ -291,8 +354,14 @@ export class App {
291
354
  git,
292
355
  system,
293
356
  deps,
357
+ agents,
358
+ environment,
294
359
  project,
295
- scripts: readScripts(packageJson),
360
+ verifySteps: resolvedVerify.steps,
361
+ verify: { configured: resolvedVerify.steps.length, running: false, steps: [] },
362
+ verifyIssues: [...loaded.issues.filter(isVerifyIssue), ...resolvedVerify.issues],
363
+ verifyAbort: null,
364
+ scripts,
296
365
  scriptRuns: {},
297
366
  wiring: new Subscriptions(),
298
367
  timers: [],
@@ -307,6 +376,12 @@ export class App {
307
376
  this.commands.registerAll(
308
377
  createConfigCommands(this.config.commands, (entry) => this.runConfigCommand(entry)),
309
378
  )
379
+ this.commands.registerAll(
380
+ createControlPlaneCommands({
381
+ agents: this.session.agents.list(),
382
+ machines: this.config.remoteMachines,
383
+ }),
384
+ )
310
385
  }
311
386
 
312
387
  private reportConfigIssues(): void {
@@ -366,6 +441,7 @@ export class App {
366
441
  new ServicesView(deps),
367
442
  new ScriptsView(deps),
368
443
  new GitView(deps),
444
+ new WorktreesView(deps),
369
445
  new LogsView(deps),
370
446
  new DependenciesView(deps),
371
447
  new SystemView(deps),
@@ -429,13 +505,13 @@ export class App {
429
505
  */
430
506
  private wireSession(session: ProjectSession): void {
431
507
  const { store } = this
432
- const { wiring, processes, services, git, system, logs, deps } = session
508
+ const { wiring, processes, services, git, system, logs, deps, environment } = session
433
509
  const active = () => this.session === session
434
510
  const notify = (level: NotificationLevel, message: string, detail?: string) =>
435
511
  this.pushNotification(level, active() ? message : `[${session.project.name}] ${message}`, detail)
436
512
 
437
513
  const syncProcesses = () => {
438
- if (active()) store.patch({ processes: processes.list() })
514
+ if (active()) store.patch({ processes: processes.list(), agents: session.agents.sessions() })
439
515
  this.refreshSidebarProjects()
440
516
  }
441
517
  processes.on("process:changed", syncProcesses)
@@ -473,11 +549,16 @@ export class App {
473
549
  system.on("system:metrics", (metrics) => {
474
550
  if (active()) store.patch({ system: metrics })
475
551
  })
476
- system.on("system:tools", (tools) => {
552
+ wiring.add(() => system.removeAllListeners())
553
+
554
+ environment.on("environment:changed", (snapshot) => {
555
+ const tools = toolVersionsFrom(snapshot, session.project.packageManager.manager)
477
556
  session.project = { ...session.project, tools }
478
- if (active()) store.setState((state) => ({ ...state, project: { ...state.project, tools } }))
557
+ if (active()) {
558
+ store.setState((state) => ({ ...state, environment: snapshot, project: { ...state.project, tools } }))
559
+ }
479
560
  })
480
- wiring.add(() => system.removeAllListeners())
561
+ wiring.add(() => environment.removeAllListeners())
481
562
 
482
563
  logs.on("log:sources", (sources) => {
483
564
  if (!active()) return
@@ -540,6 +621,9 @@ export class App {
540
621
  this.subscriptions.add(store.watch((state) => state.services, markDirty, shallowArrayEqual))
541
622
  this.subscriptions.add(store.watch((state) => state.git, markDirty))
542
623
  this.subscriptions.add(store.watch((state) => state.dependencies, markDirty))
624
+ this.subscriptions.add(store.watch((state) => state.agents, markDirty, shallowArrayEqual))
625
+ this.subscriptions.add(store.watch((state) => state.environment, markDirty))
626
+ this.subscriptions.add(store.watch((state) => state.verify, markDirty))
543
627
  this.subscriptions.add(store.watch((state) => state.projects, markDirty, shallowArrayEqual))
544
628
  this.subscriptions.add(store.watch((state) => state.system, markDirty))
545
629
  this.subscriptions.add(store.watch((state) => state.scripts, markDirty))
@@ -557,6 +641,7 @@ export class App {
557
641
  processes: this.processes.list(),
558
642
  services: this.services.list(),
559
643
  logSources: this.logs.sources(),
644
+ verify: this.session.verify,
560
645
  })
561
646
  store.setState((state) => ({
562
647
  ...state,
@@ -596,7 +681,8 @@ export class App {
596
681
 
597
682
  void session.git.refresh()
598
683
  void session.services.detectAll().then(() => session.services.start())
599
- void session.system.refreshTools(session.project.packageManager.manager)
684
+ // Tool versions change rarely: detect once per project, refresh on request.
685
+ if (!session.environment.getSnapshot().checkedAt) void session.environment.refresh()
600
686
  if (!session.processes.list().some((entry) => entry.startedAt)) {
601
687
  // Autostart only the first time a project is opened, never on reactivation.
602
688
  void session.processes.startAutoStart()
@@ -759,6 +845,11 @@ export class App {
759
845
  void this.commands.run("app.refresh", this.context)
760
846
  return
761
847
  }
848
+ if (key.shift && key.name === "v") {
849
+ key.preventDefault()
850
+ void this.runCommand("verify.run")
851
+ return
852
+ }
762
853
 
763
854
  const state = this.store.getState()
764
855
 
@@ -1035,6 +1126,8 @@ export class App {
1035
1126
  logs: this.logs,
1036
1127
  system: this.system,
1037
1128
  deps: this.deps,
1129
+ agents: this.agents,
1130
+ environment: this.environment,
1038
1131
  navigate: (view) => updateUi(this.store, { view }),
1039
1132
  notify: (level, message, detail) => this.pushNotification(level, message, detail),
1040
1133
  confirm: (title, message) => this.askConfirm(title, message),
@@ -1046,6 +1139,15 @@ export class App {
1046
1139
  openProjects: () => this.openProjects(),
1047
1140
  switchProject: (path) => this.openProject(path),
1048
1141
  closeProject: (path) => this.closeProject(path),
1142
+ selectedWorktree: () => this.selectedWorktree(),
1143
+ createWorktree: () => this.createWorktree(),
1144
+ removeWorktree: (worktree) => this.removeWorktree(worktree),
1145
+ launchAgent: (agentId, worktree) => this.launchAgent(agentId, worktree),
1146
+ openInEditor: (path) => this.launch(() => editorCommand(path, this.config.editor), path),
1147
+ openInTerminal: (path) => this.launch(() => terminalCommand(path, this.config.terminal), path),
1148
+ runVerify: () => this.runVerify(),
1149
+ remoteMachines: () => this.config.remoteMachines,
1150
+ sshTo: (machineId) => this.sshTo(machineId),
1049
1151
  quit: () => void this.quit(),
1050
1152
  }
1051
1153
 
@@ -1058,6 +1160,8 @@ export class App {
1058
1160
  logs: { get: () => this.logs, enumerable: true },
1059
1161
  system: { get: () => this.system, enumerable: true },
1060
1162
  deps: { get: () => this.deps, enumerable: true },
1163
+ agents: { get: () => this.agents, enumerable: true },
1164
+ environment: { get: () => this.environment, enumerable: true },
1061
1165
  })
1062
1166
 
1063
1167
  return context
@@ -1262,6 +1366,9 @@ export class App {
1262
1366
  scripts: { available: session.scripts, runs: session.scriptRuns },
1263
1367
  git: session.git.getSnapshot(),
1264
1368
  dependencies: session.deps.getSnapshot(),
1369
+ agents: session.agents.sessions(),
1370
+ environment: session.environment.getSnapshot(),
1371
+ verify: session.verify,
1265
1372
  system: session.system.getMetrics(),
1266
1373
  logSources: session.logs.sources(),
1267
1374
  projects: [],
@@ -1349,6 +1456,240 @@ export class App {
1349
1456
  )
1350
1457
  }
1351
1458
 
1459
+ // ---------------------------------------------------------------------
1460
+ // Worktrees, agents, verification, remote
1461
+ // ---------------------------------------------------------------------
1462
+
1463
+ private selectedWorktree(): GitWorktree | undefined {
1464
+ const state = this.store.getState()
1465
+ if (state.ui.view === "worktrees") {
1466
+ const entry = selectedWorktreeEntry(state)
1467
+ if (entry?.worktree) return entry.worktree
1468
+ }
1469
+ return state.git.worktrees.find((worktree) => worktree.isCurrent)
1470
+ }
1471
+
1472
+ private async createWorktree(): Promise<void> {
1473
+ const snapshot = this.git.getSnapshot()
1474
+ if (!snapshot.isRepository) {
1475
+ this.pushNotification("warn", "Not a git repository")
1476
+ return
1477
+ }
1478
+ const main = snapshot.worktrees.find((worktree) => worktree.isMain)
1479
+ if (!main) {
1480
+ this.pushNotification("warn", "Could not read this repository's worktrees yet")
1481
+ return
1482
+ }
1483
+
1484
+ const branch = (
1485
+ await this.askPrompt("New worktree: branch (created if it does not exist)", "feature/name")
1486
+ )?.trim()
1487
+ if (!branch) return
1488
+ const current = snapshot.status.branch ?? "HEAD"
1489
+ const base = await this.askPrompt(`Base for ${branch} (empty: ${current})`, current)
1490
+ if (base === null) return
1491
+ const suggested = defaultWorktreePath(main.path, branch, this.config.worktrees?.directory)
1492
+ const path = await this.askPrompt(`Directory (empty: ${tildify(suggested)})`, tildify(suggested))
1493
+ if (path === null) return
1494
+
1495
+ const target = path.trim() === "" ? suggested : resolve(main.path, expandHome(path.trim()))
1496
+ try {
1497
+ this.footer.setStatus(`Creating worktree ${branch}…`)
1498
+ await this.git.createWorktree({ branch, base: base.trim() || undefined, path: target })
1499
+ this.pushNotification("success", `Created worktree ${branch} at ${tildify(target)}`)
1500
+ } catch (error) {
1501
+ const devccError = toDevccError(error, "git")
1502
+ this.pushNotification("error", devccError.message, devccError.detail)
1503
+ }
1504
+ }
1505
+
1506
+ private async removeWorktree(worktree: GitWorktree): Promise<void> {
1507
+ const busy = [
1508
+ ...this.agents.busyPaths(),
1509
+ ...this.processes
1510
+ .list()
1511
+ .filter((entry) => isActive(entry.status))
1512
+ .map((entry) => resolve(this.session.rootDir, entry.cwd ?? ".")),
1513
+ // A worktree open as a project in devcc is in use too.
1514
+ ...[...this.sessions.keys()].filter((path) => path !== this.session.rootDir),
1515
+ ]
1516
+ const blocker = removalBlocker(worktree, busy)
1517
+ if (blocker) {
1518
+ this.pushNotification("warn", blocker)
1519
+ return
1520
+ }
1521
+
1522
+ const confirmed = await this.askConfirm(
1523
+ `Remove worktree ${worktreeLabel(worktree)}?`,
1524
+ `Deletes ${tildify(worktree.path)}. The branch is kept. git refuses if there are uncommitted or untracked changes; devcc never forces it.`,
1525
+ )
1526
+ if (!confirmed) return
1527
+
1528
+ try {
1529
+ await this.git.removeWorktree(worktree.path, busy)
1530
+ this.pushNotification("success", `Removed worktree ${worktreeLabel(worktree)}`)
1531
+ } catch (error) {
1532
+ const devccError = toDevccError(error, "git")
1533
+ this.pushNotification("error", devccError.message, devccError.detail)
1534
+ }
1535
+ }
1536
+
1537
+ private async launchAgent(agentId: string, worktree?: GitWorktree): Promise<void> {
1538
+ const adapter = this.agents.get(agentId)
1539
+ if (!adapter) {
1540
+ this.pushNotification("warn", `Unknown agent "${agentId}"`)
1541
+ return
1542
+ }
1543
+ const target = worktree ?? this.selectedWorktree()
1544
+ const cwd = target?.path ?? this.session.rootDir
1545
+ const where = target ? worktreeLabel(target) : basename(cwd)
1546
+
1547
+ const task = await this.askPrompt(
1548
+ `Task for ${adapter.name} in ${where}`,
1549
+ "Describe what the agent should do",
1550
+ )
1551
+ if (task === null || task.trim() === "") return
1552
+
1553
+ try {
1554
+ const session = await this.agents.launch(agentId, { cwd, prompt: task, branch: target?.branch })
1555
+ updateUi(this.store, { logSource: session.id })
1556
+ this.pushNotification("success", `${adapter.name} started in ${where}`)
1557
+ } catch (error) {
1558
+ const devccError = toDevccError(error, "process")
1559
+ this.pushNotification("error", devccError.message, devccError.detail)
1560
+ }
1561
+ }
1562
+
1563
+ /** Build and start an external launch; a missing configuration becomes a notification. */
1564
+ private async launch(build: () => LaunchCommand, cwd: string): Promise<void> {
1565
+ try {
1566
+ const command = build()
1567
+ await launchDetached(command, cwd)
1568
+ this.logger.log("debug", "launch", "opened", { command: command.command, cwd })
1569
+ } catch (error) {
1570
+ const devccError = toDevccError(error, "command")
1571
+ this.pushNotification("error", devccError.message, devccError.detail)
1572
+ }
1573
+ }
1574
+
1575
+ private async sshTo(machineId: string): Promise<void> {
1576
+ const machine = this.config.remoteMachines.find((entry) => entry.id === machineId)
1577
+ if (!machine) {
1578
+ this.pushNotification("warn", `Unknown remote machine "${machineId}"`)
1579
+ return
1580
+ }
1581
+ await this.launch(() => sshCommand(machine, this.config.terminal), this.session.rootDir)
1582
+ }
1583
+
1584
+ /**
1585
+ * Run verification through the ProcessManager: each step is a managed task,
1586
+ * so its output streams into Logs and quitting stops it like anything else.
1587
+ */
1588
+ private async runVerify(): Promise<void> {
1589
+ const session = this.session
1590
+ if (session.verify.running) {
1591
+ this.pushNotification("warn", "Verification is already running")
1592
+ return
1593
+ }
1594
+ if (session.verifyIssues.length > 0) {
1595
+ this.pushNotification(
1596
+ "error",
1597
+ `Verify refused: ${session.verifyIssues[0] ?? "invalid verify config"}`,
1598
+ session.verifyIssues.join("\n"),
1599
+ )
1600
+ return
1601
+ }
1602
+ if (session.verifySteps.length === 0) {
1603
+ this.pushNotification(
1604
+ "warn",
1605
+ "No verification steps configured",
1606
+ 'Add verify: ["lint", "test"] to .devcc.ts',
1607
+ )
1608
+ return
1609
+ }
1610
+
1611
+ const controller = new AbortController()
1612
+ session.verifyAbort = controller
1613
+ const publish = (verify: VerifyState) => {
1614
+ session.verify = verify
1615
+ if (session === this.session) this.store.patch({ verify })
1616
+ }
1617
+ const startedAt = new Date()
1618
+ publish({ configured: session.verifySteps.length, running: true, steps: [], startedAt })
1619
+ this.pushNotification("info", `Verifying: ${session.verifySteps.map((step) => step.name).join(", ")}`)
1620
+
1621
+ const report = await runVerification(session.verifySteps, this.processStepExecutor(session), {
1622
+ signal: controller.signal,
1623
+ onProgress: (steps) => publish({ ...session.verify, steps: [...steps] }),
1624
+ })
1625
+
1626
+ session.verifyAbort = null
1627
+ publish({
1628
+ configured: session.verifySteps.length,
1629
+ running: false,
1630
+ steps: report.steps,
1631
+ startedAt,
1632
+ finishedAt: new Date(),
1633
+ ok: report.ok,
1634
+ })
1635
+ this.pushNotification(report.ok ? "success" : "error", `Verify: ${summariseVerify(report)}`)
1636
+ }
1637
+
1638
+ private processStepExecutor(session: ProjectSession): StepExecutor {
1639
+ return async (step, signal) => {
1640
+ const { processes } = session
1641
+ const id = `verify:${step.id}`
1642
+ if (processes.isRunning(id)) return { exitCode: null, error: "already running" }
1643
+
1644
+ processes.register({
1645
+ id,
1646
+ name: `verify · ${step.name}`,
1647
+ command: step.command,
1648
+ args: step.args,
1649
+ cwd: step.cwd,
1650
+ env: { NO_COLOR: "1" },
1651
+ kind: "task",
1652
+ })
1653
+
1654
+ let onChange: ((snapshot: ManagedProcess) => void) | undefined
1655
+ const finished = new Promise<ManagedProcess>((resolveFinished) => {
1656
+ let started = false
1657
+ onChange = (snapshot: ManagedProcess) => {
1658
+ if (snapshot.id !== id) return
1659
+ if (snapshot.status === "starting" || snapshot.status === "running") started = true
1660
+ else if (started && (snapshot.status === "stopped" || snapshot.status === "failed")) {
1661
+ resolveFinished(snapshot)
1662
+ }
1663
+ }
1664
+ processes.on("process:changed", onChange)
1665
+ })
1666
+ const onAbort = () => void processes.stop(id).catch(() => undefined)
1667
+ signal.addEventListener("abort", onAbort, { once: true })
1668
+
1669
+ try {
1670
+ if (session === this.session) updateUi(this.store, { logSource: id })
1671
+ await processes.start(id)
1672
+ const result = await finished
1673
+ return {
1674
+ exitCode: result.exitCode ?? null,
1675
+ error: signal.aborted
1676
+ ? "cancelled"
1677
+ : result.exitSignal
1678
+ ? `terminated by ${result.exitSignal}`
1679
+ : result.exitCode === undefined
1680
+ ? result.lastError
1681
+ : undefined,
1682
+ }
1683
+ } catch (error) {
1684
+ return { exitCode: null, error: errorMessage(error) }
1685
+ } finally {
1686
+ signal.removeEventListener("abort", onAbort)
1687
+ // Also runs when start() throws, so the listener never outlives the step.
1688
+ if (onChange) processes.off("process:changed", onChange)
1689
+ }
1690
+ }
1691
+ }
1692
+
1352
1693
  // ---------------------------------------------------------------------
1353
1694
  // Dependencies
1354
1695
  // ---------------------------------------------------------------------
@@ -1404,6 +1745,7 @@ export class App {
1404
1745
  this.footer.setStatus("Shutting down…")
1405
1746
 
1406
1747
  this.stopBackground()
1748
+ for (const session of this.sessions.values()) session.verifyAbort?.abort()
1407
1749
 
1408
1750
  // Every open project, not just the visible one.
1409
1751
  await Promise.all(
@@ -1461,3 +1803,17 @@ export class App {
1461
1803
  return this.logs
1462
1804
  }
1463
1805
  }
1806
+
1807
+ /** The header and System view still read `project.tools`; derive it from environment detection. */
1808
+ function toolVersionsFrom(snapshot: EnvironmentSnapshot, packageManager: string): ToolVersions {
1809
+ const version = (id: string) => {
1810
+ const tool = snapshot.tools.find((entry) => entry.id === id)
1811
+ return tool?.installed ? (tool.version ?? "installed") : null
1812
+ }
1813
+ return { git: version("git"), docker: version("docker"), packageManager: version(packageManager) }
1814
+ }
1815
+
1816
+ function expandHome(path: string): string {
1817
+ const home = process.env.HOME
1818
+ return home && (path === "~" || path.startsWith("~/")) ? `${home}${path.slice(1)}` : path
1819
+ }