zugzbot 1.1.6 → 1.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.
@@ -1,6 +1,7 @@
1
1
  import { tool } from "@opencode-ai/plugin"
2
2
  import fs from "fs"
3
3
  import path from "path"
4
+ import crypto from "crypto"
4
5
  import { execSync, spawn } from "child_process"
5
6
 
6
7
  // Helper to safely resolve root directory (avoiding OpenCode bug where worktree is '/' in non-git repos)
@@ -20,7 +21,7 @@ const getTargetDir = (root: string): string => {
20
21
  if (data && data.targetDir) return data.targetDir
21
22
  }
22
23
  } catch (e) {}
23
-
24
+
24
25
  try {
25
26
  const statePath = path.resolve(root, ".openspec/sdd_state.json")
26
27
  if (fs.existsSync(statePath)) {
@@ -32,9 +33,68 @@ const getTargetDir = (root: string): string => {
32
33
  return "."
33
34
  }
34
35
 
35
- // Helper to get PID file path
36
+ // ============================================================================
37
+ // Unix-style PID/PGID storage in /tmp (bugfix sesion 1176: servidor caia al
38
+ // cerrar subagente; ademas chocaba entre proyectos zugzbot simultaneos).
39
+ //
40
+ // Esquema:
41
+ // /tmp/zugzbot-dev-server-<hash8> -> PID (referencia principal)
42
+ // /tmp/zugzbot-dev-server-<hash8>.pgid -> PGID (para kill -SIGTERM -PGID)
43
+ //
44
+ // hash8 = primeros 8 chars de md5(root) para evitar colisiones entre
45
+ // distintos proyectos zugzbot abiertos en paralelo.
46
+ // ============================================================================
47
+
48
+ const projectHash = (root: string): string => {
49
+ return crypto.createHash("md5").update(root).digest("hex").slice(0, 8)
50
+ }
51
+
36
52
  const getPidFilePath = (root: string) => {
37
- return path.resolve(root, ".openspec/dev_server.pid")
53
+ return `/tmp/zugzbot-dev-server-${projectHash(root)}`
54
+ }
55
+ const getPgidFilePath = (root: string) => {
56
+ return `/tmp/zugzbot-dev-server-${projectHash(root)}.pgid`
57
+ }
58
+
59
+ const readPidFiles = (root: string): { pid: number | null; pgid: number | null } => {
60
+ let pid: number | null = null
61
+ let pgid: number | null = null
62
+ try {
63
+ if (fs.existsSync(getPidFilePath(root))) {
64
+ pid = parseInt(fs.readFileSync(getPidFilePath(root), "utf8").trim(), 10)
65
+ if (!Number.isFinite(pid)) pid = null
66
+ }
67
+ } catch (e) {}
68
+ try {
69
+ if (fs.existsSync(getPgidFilePath(root))) {
70
+ pgid = parseInt(fs.readFileSync(getPgidFilePath(root), "utf8").trim(), 10)
71
+ if (!Number.isFinite(pgid)) pgid = null
72
+ }
73
+ } catch (e) {}
74
+ return { pid, pgid }
75
+ }
76
+
77
+ const clearPidFiles = (root: string) => {
78
+ try { fs.unlinkSync(getPidFilePath(root)) } catch (e) {}
79
+ try { fs.unlinkSync(getPgidFilePath(root)) } catch (e) {}
80
+ }
81
+
82
+ const killProcessGroup = (pgid: number, signal: NodeJS.Signals = "SIGTERM"): boolean => {
83
+ try {
84
+ process.kill(-pgid, signal)
85
+ return true
86
+ } catch (e) {
87
+ return false
88
+ }
89
+ }
90
+
91
+ const isPidAlive = (pid: number): boolean => {
92
+ try {
93
+ process.kill(pid, 0)
94
+ return true
95
+ } catch (e) {
96
+ return false
97
+ }
38
98
  }
39
99
 
40
100
  // Tool: sdd_free_port
@@ -68,8 +128,17 @@ export const free_port = tool({
68
128
  })
69
129
 
70
130
  // Tool: sdd_start_server
131
+ //
132
+ // Bugfix sesion 1176: el dev server caia al cerrar el subagente coder porque
133
+ // el proceso hijo hereda el process group del opencode padre y moría con él.
134
+ // Solucion: envolver el spawn con `setsid` (disponible en macOS/Linux) para
135
+ // crear un nuevo session leader completamente independiente, y guardar tanto
136
+ // el PID como el PGID en /tmp para poder matar todo el grupo limpiamente.
71
137
  export const start_server = tool({
72
- description: "Inicia un servidor en segundo plano y registra su PID.",
138
+ description:
139
+ "Inicia un servidor en segundo plano y registra su PID/PGID en /tmp. " +
140
+ "El proceso se desacopla del opencode padre con `setsid`, de modo que " +
141
+ "sobrevive al cierre del subagente que lo lanzó (bugfix sesion 1176).",
73
142
  args: {
74
143
  command: tool.schema.string().describe("Comando para iniciar el servidor (ej. 'yarn dev' o 'npm run dev')"),
75
144
  port: tool.schema.number().optional().default(3000).describe("Puerto esperado del servidor (default: 3000)"),
@@ -80,21 +149,45 @@ export const start_server = tool({
80
149
  const targetDir = getTargetDir(root)
81
150
  const targetCwd = args.cwd ? path.resolve(root, args.cwd) : (targetDir === "." ? root : path.resolve(root, targetDir))
82
151
  const pidFile = getPidFilePath(root)
152
+ const pgidFile = getPgidFilePath(root)
83
153
 
154
+ // Liberar puerto si esta ocupado (limpieza previa)
84
155
  try {
85
156
  const existingPid = execSync(`lsof -t -i :${args.port}`).toString().trim()
86
157
  if (existingPid) {
87
158
  execSync(`kill -9 ${existingPid.split('\n').join(' ')}`)
88
159
  }
89
160
  } catch (e) {
90
- // ignore
161
+ // ignore: puerto libre
91
162
  }
92
163
 
164
+ // Si hay un PID previo registrado, intentar matarlo limpiamente primero
165
+ const prev = readPidFiles(root)
166
+ if (prev.pgid && isPidAlive(prev.pgid)) {
167
+ killProcessGroup(prev.pgid, "SIGTERM")
168
+ await new Promise(r => setTimeout(r, 500))
169
+ if (isPidAlive(prev.pgid)) {
170
+ killProcessGroup(prev.pgid, "SIGKILL")
171
+ }
172
+ } else if (prev.pid && isPidAlive(prev.pid)) {
173
+ try { process.kill(prev.pid, "SIGKILL") } catch (e) {}
174
+ }
175
+ clearPidFiles(root)
176
+
93
177
  const parts = args.command.split(" ")
94
178
  const cmd = parts[0]
95
179
  const cmdArgs = parts.slice(1)
96
180
 
97
- const child = spawn(cmd, cmdArgs, {
181
+ // Usar setsid para crear un nuevo session leader. Asi, cuando el subagente
182
+ // coder termine, el dev server no es killed por SIGTERM en cascada.
183
+ // setsid existe en macOS (coreutils) y Linux por defecto.
184
+ const useSetsid = process.platform !== "win32"
185
+
186
+ const spawnArgs = useSetsid
187
+ ? { cmd: "setsid", cmdArgs: [cmd, ...cmdArgs] }
188
+ : { cmd, cmdArgs }
189
+
190
+ const child = spawn(spawnArgs.cmd, spawnArgs.cmdArgs, {
98
191
  cwd: targetCwd,
99
192
  detached: true,
100
193
  stdio: "ignore",
@@ -104,22 +197,37 @@ export const start_server = tool({
104
197
  }
105
198
  })
106
199
 
107
- child.unref()
200
+ // CRITICO: NO usar child.unref() — necesitamos mantener la referencia
201
+ // para que el proceso no sea reaped inmediatamente y para tener el PGID
202
+ // disponible. Sin embargo, el spawn fue `detached: true` con setsid, asi
203
+ // que el proceso ya esta en su propio session group.
204
+ //
205
+ // El handler exit solo borra los PID files si el proceso muere solo.
206
+ child.on('exit', () => {
207
+ clearPidFiles(root)
208
+ })
209
+ child.on('error', () => {
210
+ clearPidFiles(root)
211
+ })
108
212
 
109
213
  const pid = child.pid
110
214
  if (pid) {
111
- const dir = path.dirname(pidFile)
112
- if (!fs.existsSync(dir)) {
113
- fs.mkdirSync(dir, { recursive: true })
114
- }
215
+ // En setsid, el PID del proceso spawned es el PGID del nuevo grupo
216
+ const pgid = pid
115
217
  fs.writeFileSync(pidFile, String(pid), "utf8")
218
+ fs.writeFileSync(pgidFile, String(pgid), "utf8")
116
219
 
220
+ // Esperar a que arranque
117
221
  await new Promise(resolve => setTimeout(resolve, 3000))
118
222
 
119
223
  return JSON.stringify({
120
224
  status: "SUCCESS",
121
- message: `Servidor iniciado con PID ${pid} ejecutando '${args.command}' en puerto ${args.port}`,
122
- pid
225
+ message: `Servidor iniciado con PID ${pid} (PGID ${pgid}) ejecutando '${args.command}' en puerto ${args.port}. PID file: ${pidFile}`,
226
+ pid,
227
+ pgid,
228
+ pid_file: pidFile,
229
+ pgid_file: pgidFile,
230
+ detached_via: useSetsid ? "setsid" : "none"
123
231
  }, null, 2)
124
232
  }
125
233
 
@@ -131,44 +239,101 @@ export const start_server = tool({
131
239
  })
132
240
 
133
241
  // Tool: sdd_stop_server
242
+ //
243
+ // Mata el grupo de procesos completo (SIGTERM graceful, luego SIGKILL si
244
+ // no responde en 2s). Esto libera el puerto automaticamente.
134
245
  export const stop_server = tool({
135
- description: "Detiene el servidor en segundo plano registrado.",
246
+ description:
247
+ "Detiene el servidor en segundo plano registrado. Mata el process group " +
248
+ "completo (SIGTERM, fallback SIGKILL tras 2s) usando el PGID guardado en /tmp.",
136
249
  args: {},
137
250
  async execute(args, context) {
138
251
  const root = getRoot(context)
139
- const pidFile = getPidFilePath(root)
252
+ const { pid, pgid } = readPidFiles(root)
253
+
254
+ if (!pid && !pgid) {
255
+ return JSON.stringify({
256
+ status: "SUCCESS",
257
+ message: "No hay ningún servidor registrado activo"
258
+ }, null, 2)
259
+ }
140
260
 
141
- if (fs.existsSync(pidFile)) {
142
- try {
143
- const pidStr = fs.readFileSync(pidFile, "utf8").trim()
144
- const pid = parseInt(pidStr, 10)
145
- if (!isNaN(pid)) {
146
- try {
147
- process.kill(-pid, "SIGKILL")
148
- } catch (err) {
149
- try {
150
- process.kill(pid, "SIGKILL")
151
- } catch (err2) {
152
- // ignore
153
- }
154
- }
155
- fs.unlinkSync(pidFile)
156
- return JSON.stringify({
157
- status: "SUCCESS",
158
- message: `Servidor con PID ${pid} terminado exitosamente`
159
- }, null, 2)
261
+ let killed = false
262
+ let signal_used: string | null = null
263
+
264
+ if (pgid && isPidAlive(pgid)) {
265
+ // SIGTERM graceful primero
266
+ if (killProcessGroup(pgid, "SIGTERM")) {
267
+ signal_used = "SIGTERM"
268
+ killed = true
269
+ }
270
+ // Esperar hasta 2s
271
+ await new Promise(r => setTimeout(r, 2000))
272
+ // Si sigue vivo, SIGKILL
273
+ if (isPidAlive(pgid)) {
274
+ if (killProcessGroup(pgid, "SIGKILL")) {
275
+ signal_used = signal_used ? `${signal_used}+SIGKILL` : "SIGKILL"
160
276
  }
161
- } catch (e) {
162
- return JSON.stringify({
163
- status: "ERROR",
164
- message: `Error al intentar detener el servidor: ${(e as Error).message}`
165
- }, null, 2)
277
+ }
278
+ } else if (pid && isPidAlive(pid)) {
279
+ // Fallback: matar solo el PID
280
+ try { process.kill(pid, "SIGTERM"); signal_used = "SIGTERM"; killed = true } catch (e) {}
281
+ await new Promise(r => setTimeout(r, 2000))
282
+ if (isPidAlive(pid)) {
283
+ try { process.kill(pid, "SIGKILL"); signal_used = "SIGKILL" } catch (e) {}
166
284
  }
167
285
  }
168
286
 
287
+ clearPidFiles(root)
288
+
169
289
  return JSON.stringify({
170
- status: "SUCCESS",
171
- message: "No hay ningún servidor registrado activo"
290
+ status: killed ? "SUCCESS" : "WARNING",
291
+ message: killed
292
+ ? `Servidor (PID ${pid}, PGID ${pgid}) terminado via ${signal_used}`
293
+ : `PID/PGID ${pid}/${pgid} no estaba vivo (limpieza de archivos)`,
294
+ pid,
295
+ pgid,
296
+ signal_used
172
297
  }, null, 2)
173
298
  }
174
299
  })
300
+
301
+ // Tool: sdd_server_status (utilidad: NUEVO)
302
+ //
303
+ // Inspecciona si el dev server sigue vivo leyendo /tmp.
304
+ export const server_status = tool({
305
+ description:
306
+ "Verifica si el dev server registrado sigue vivo. Lee los archivos PID/PGID " +
307
+ "de /tmp y consulta el puerto objetivo.",
308
+ args: {
309
+ port: tool.schema.number().optional().default(3000).describe("Puerto esperado del servidor (default: 3000)")
310
+ },
311
+ async execute(args, context) {
312
+ const root = getRoot(context)
313
+ const { pid, pgid } = readPidFiles(root)
314
+ const pidAlive = pid ? isPidAlive(pid) : false
315
+ const pgidAlive = pgid ? isPidAlive(pgid) : false
316
+
317
+ let portListening = false
318
+ try {
319
+ const out = execSync(`lsof -i :${args.port} -sTCP:LISTEN -t 2>/dev/null`).toString().trim()
320
+ portListening = !!out
321
+ } catch (e) {
322
+ portListening = false
323
+ }
324
+
325
+ return JSON.stringify({
326
+ status: portListening ? "RUNNING" : "STOPPED",
327
+ message: portListening
328
+ ? `Dev server respondiendo en puerto ${args.port}`
329
+ : `No hay dev server activo en puerto ${args.port}`,
330
+ pid_file: getPidFilePath(root),
331
+ pgid_file: getPgidFilePath(root),
332
+ pid,
333
+ pgid,
334
+ pid_alive: pidAlive,
335
+ pgid_alive: pgidAlive,
336
+ port_listening: portListening
337
+ }, null, 2)
338
+ }
339
+ })
@@ -248,9 +248,14 @@ export const shift_left_verify = tool({
248
248
  }, null, 2)
249
249
  }
250
250
 
251
- const result: { tsc: { status: string, errors?: any[] }, eslint: { status: string, errors?: any[] } } = {
251
+ const result: {
252
+ tsc: { status: string, errors?: any[] }
253
+ eslint: { status: string, errors?: any[] }
254
+ lintCharts: { status: string, violations?: any[], summary?: string }
255
+ } = {
252
256
  tsc: { status: "SUCCESS" },
253
- eslint: { status: "SUCCESS" }
257
+ eslint: { status: "SUCCESS" },
258
+ lintCharts: { status: "SUCCESS" }
254
259
  }
255
260
 
256
261
  // 1. Run tsc --noEmit
@@ -265,6 +270,59 @@ export const shift_left_verify = tool({
265
270
  }
266
271
  }
267
272
 
273
+ // 1.5 Run lint-charts (bugfix sesion 1176: detectar hsl(var(--chart-N)) en
274
+ // chartConfig que es CSS invalido cuando --chart-N esta definido en OKLCH).
275
+ // BLOQUEANTE: cualquier violacion hace fallar shift-left.
276
+ try {
277
+ const linterScript = path.resolve(root, ".opencode/scripts/lint-charts.js")
278
+ if (fs.existsSync(linterScript)) {
279
+ const lintOut = execSync(`node "${linterScript}" --json`, {
280
+ cwd: targetPath,
281
+ encoding: "utf8",
282
+ timeout: 30000
283
+ })
284
+ try {
285
+ const parsed = JSON.parse(lintOut)
286
+ if (parsed.status === "FAIL") {
287
+ result.lintCharts = {
288
+ status: "FAIL",
289
+ violations: parsed.violations || [],
290
+ summary: parsed.summary || "violaciones detectadas"
291
+ }
292
+ } else {
293
+ result.lintCharts = {
294
+ status: "SUCCESS",
295
+ summary: parsed.summary || "ok"
296
+ }
297
+ }
298
+ } catch (parseErr) {
299
+ result.lintCharts = {
300
+ status: "WARN",
301
+ summary: "no se pudo parsear salida del linter: " + lintOut.slice(0, 200)
302
+ }
303
+ }
304
+ }
305
+ } catch (e: any) {
306
+ // execSync lanza si exit code != 0 (linter encontro violaciones)
307
+ const rawOutput = e.stdout?.toString() || e.stderr?.toString() || ""
308
+ try {
309
+ const parsed = JSON.parse(rawOutput)
310
+ if (parsed.status === "FAIL") {
311
+ result.lintCharts = {
312
+ status: "FAIL",
313
+ violations: parsed.violations || [],
314
+ summary: parsed.summary || "violaciones detectadas"
315
+ }
316
+ }
317
+ } catch (parseErr) {
318
+ result.lintCharts = {
319
+ status: "FAIL",
320
+ violations: [{ raw: rawOutput.slice(0, 1000) }],
321
+ summary: "linter ejecuto con codigo no-cero y salida no parseable"
322
+ }
323
+ }
324
+ }
325
+
268
326
  // 2. Run eslint targeting modified files dynamically
269
327
  let eslintFiles = "src/"
270
328
  try {
@@ -311,16 +369,83 @@ export const shift_left_verify = tool({
311
369
  }
312
370
  }
313
371
 
314
- const overallSuccess = result.tsc.status === "SUCCESS" && result.eslint.status === "SUCCESS"
372
+ const overallSuccess =
373
+ result.tsc.status === "SUCCESS" &&
374
+ result.eslint.status === "SUCCESS" &&
375
+ result.lintCharts.status === "SUCCESS"
315
376
 
316
377
  return JSON.stringify({
317
378
  status: overallSuccess ? "SUCCESS" : "FAIL",
318
- message: overallSuccess ? "Shift-Left Verification exitosa: Cero errores de compilación y cero errores de lint." : "Validación fallida: Se encontraron errores estáticos.",
379
+ message: overallSuccess
380
+ ? "Shift-Left Verification exitosa: Cero errores de compilación, lint y lint-charts."
381
+ : "Validación fallida: Se encontraron errores estáticos.",
319
382
  verification: result
320
383
  }, null, 2)
321
384
  }
322
385
  })
323
386
 
387
+ // Tool: sdd_lint_charts
388
+ //
389
+ // Linter BLOQUEANTE independiente para invocación directa (debugging o
390
+ // pre-commit). Detecta uso incorrecto de `hsl(var(--chart-N))` cuando
391
+ // `--chart-N` esta definido en OKLCH en globals.css (sesion 1176).
392
+ export const lint_charts = tool({
393
+ description:
394
+ "Ejecuta el linter BLOQUEANTE de chartConfigs Recharts. Detecta " +
395
+ "'hsl(var(--chart-N))' y patrones similares que producen CSS invalido " +
396
+ "cuando --chart-N esta definido en OKLCH. Usar desde F3 antes de " +
397
+ "transicionar a F4 o como pre-commit hook.",
398
+ args: {
399
+ json: tool.schema
400
+ .boolean()
401
+ .optional()
402
+ .default(false)
403
+ .describe("Si true, devuelve la salida en formato JSON estructurado."),
404
+ paths: tool.schema
405
+ .array(tool.schema.string())
406
+ .optional()
407
+ .describe("Paths adicionales a escanear (default: src/ y app/).")
408
+ },
409
+ async execute(args, context) {
410
+ const root = getRoot(context)
411
+ const linterScript = path.resolve(root, ".opencode/scripts/lint-charts.js")
412
+ if (!fs.existsSync(linterScript)) {
413
+ return JSON.stringify({
414
+ status: "ERROR",
415
+ message: `Linter no encontrado en ${linterScript}. Verifica que .opencode/scripts/lint-charts.js existe.`
416
+ }, null, 2)
417
+ }
418
+
419
+ const targetDir = getTargetDir(root)
420
+ const targetPath = targetDir === "." ? root : path.resolve(root, targetDir)
421
+
422
+ const extraArgs: string[] = []
423
+ if (args.json) extraArgs.push("--json")
424
+ if (args.paths && args.paths.length > 0) extraArgs.push(...args.paths)
425
+
426
+ try {
427
+ const out = execSync(`node "${linterScript}" ${extraArgs.join(" ")}`, {
428
+ cwd: targetPath,
429
+ encoding: "utf8",
430
+ timeout: 30000
431
+ })
432
+ return JSON.stringify({
433
+ status: "PASS",
434
+ message: out.trim() || "lint-charts PASS",
435
+ raw_output: out
436
+ }, null, 2)
437
+ } catch (e: any) {
438
+ const out = e.stdout?.toString() || e.stderr?.toString() || ""
439
+ return JSON.stringify({
440
+ status: "FAIL",
441
+ message: "lint-charts detecto violaciones.",
442
+ raw_output: out,
443
+ exit_code: e.status
444
+ }, null, 2)
445
+ }
446
+ }
447
+ })
448
+
324
449
  // Tool: sdd_generate_tests
325
450
  export const generate_tests = tool({
326
451
  description: "Autogenera plantillas de pruebas a partir de los escenarios del contrato activo.",
@@ -9,7 +9,7 @@ DB_PATH = "/Users/wavesbyte/.local/share/opencode/opencode.db"
9
9
 
10
10
  # Escribe aquí el ID de la sesión que deseas exportar (ejemplo: "ses_1234...")
11
11
  # Si se deja vacío, el script requerirá el ID como argumento al ejecutarlo
12
- TARGET_SESSION_ID = "ses_1180"
12
+ TARGET_SESSION_ID = "ses_1174"
13
13
 
14
14
 
15
15
  def format_timestamp(ts):
package/opencode.json CHANGED
@@ -1,10 +1,18 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
3
  "agent": {
4
+ "sdd-orchestrator": {
5
+ "permission": {
6
+ "edit": "deny",
7
+ "write": "deny",
8
+ "bash": "deny"
9
+ },
10
+ "model": "deepseek/deepseek-v4-flash"
11
+ },
4
12
  "sdd-spec-writer": {
5
13
  "mode": "subagent",
6
14
  "permission": {
7
- "webfetch": "deny"
15
+ "webfetch": "allow"
8
16
  },
9
17
  "model": "deepseek/deepseek-v4-flash"
10
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zugzbot",
3
- "version": "1.1.6",
3
+ "version": "1.2.0",
4
4
  "description": "Fácil instalador del arnés SDD de Zugzbot para proyectos OpenCode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  ".opencode/skills/",
19
19
  ".opencode/templates/",
20
20
  ".opencode/tools/",
21
+ ".opencode/scripts/",
21
22
  ".opencode/contract-schema.json",
22
23
  ".opencode/oh-my-design/design-md/",
23
24
  ".opencode/oh-my-design/packages/mcp/dist/",