ostacky 0.10.2 → 0.10.3

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.
@@ -17,7 +17,9 @@
17
17
  // (solo tipos, borrado al transpilar) cargan sin dependencias externas.
18
18
  import type { Plugin } from "@opencode/plugin"
19
19
  import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync, readdirSync, unlinkSync } from "node:fs"
20
- import { join, dirname, basename, resolve, relative, delimiter } from "node:path"
20
+ import { join, dirname, basename, resolve, relative, delimiter, sep } from "node:path"
21
+ import { homedir } from "node:os"
22
+ import { fileURLToPath } from "node:url"
21
23
  import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from "./security.ts"
22
24
  import { isTrivial } from "./tiered.ts"
23
25
  import { STATES, TRANSITIONS, DEFAULT_STATE } from "./controller-core.ts"
@@ -30,6 +32,92 @@ const IDLE_THRESHOLD_MS = 45_000
30
32
 
31
33
  // DEFAULT_STATE imported from controller-core.ts
32
34
 
35
+ // ─── Scope (global vs local) ─────────────────────────────────────────────────
36
+ //
37
+ // OpenCode V2 dedupea plugins por `id` y conserva la PRIMERA ocurrencia en
38
+ // orden de arranque (el config global se descubre antes que el del proyecto).
39
+ // Con el mismo id en las dos copias, la global gana y la local queda
40
+ // `failed: "Duplicate plugin ID"` — el controller deja de aplicar sus gates sin
41
+ // avisar. Para que la precedencia sea local-primero (igual que el resto de la
42
+ // config de OpenCode), la copia global declara OTRO id y se retira sola cuando
43
+ // el proyecto tiene instalación local.
44
+
45
+ /**
46
+ * Config dir GLOBAL de OpenCode. Espeja `Global.Path.config` de OpenCode v2 en
47
+ * las tres plataformas: `OPENCODE_CONFIG_DIR` (override total, sin sufijo) →
48
+ * `$XDG_CONFIG_HOME/opencode` → `~/.config/opencode`. En Windows NO se usa
49
+ * `%APPDATA%` (OpenCode tampoco lo usa para config).
50
+ */
51
+ export function resolveGlobalConfigDir(opts?: {
52
+ env?: NodeJS.ProcessEnv
53
+ home?: string
54
+ }): string {
55
+ const env = opts?.env ?? process.env
56
+ const home = opts?.home ?? homedir()
57
+ const explicit = env.OPENCODE_CONFIG_DIR?.trim()
58
+ if (explicit) return explicit
59
+ const xdg = env.XDG_CONFIG_HOME?.trim()
60
+ if (xdg) return join(xdg, "opencode")
61
+ return join(home, ".config", "opencode")
62
+ }
63
+
64
+ /**
65
+ * id por copia: la global usa uno propio para no colisionar con la local.
66
+ * En win32 la comparación es case-insensitive porque los paths de Windows lo
67
+ * son: si el path real y el config dir difieren en mayúsculas, sin esto la
68
+ * copia global se clasificaría como local y volvería la colisión de ids.
69
+ */
70
+ export function pluginIdFor(
71
+ pluginDir: string,
72
+ globalDir: string,
73
+ platform: string = process.platform
74
+ ): string {
75
+ if (!pluginDir) return "ostacky-controller"
76
+ try {
77
+ const norm = (p: string) => {
78
+ const abs = resolve(p)
79
+ return platform === "win32" ? abs.toLowerCase() : abs
80
+ }
81
+ const d = norm(pluginDir)
82
+ const g = norm(globalDir)
83
+ if (d === g || d.startsWith(g + sep)) return "ostacky-controller-global"
84
+ } catch {}
85
+ return "ostacky-controller"
86
+ }
87
+
88
+ /**
89
+ * ¿El proyecto tiene el plugin instalado en scope local? Mira solo el location
90
+ * dir (lo que descubre OpenCode), no ancestros: si el local vive en un ancestro
91
+ * que el server no carga, ceder la precedencia dejaría el proyecto sin gates.
92
+ */
93
+ export function hasLocalPluginInstall(directory: string): boolean {
94
+ let dir: string
95
+ try {
96
+ dir = resolve(directory)
97
+ } catch {
98
+ return false
99
+ }
100
+ for (const kind of ["plugins", "plugin"]) {
101
+ for (const entry of ["index.ts", "index.js"]) {
102
+ try {
103
+ if (existsSync(join(dir, ".opencode", kind, "ostacky-controller", entry))) return true
104
+ } catch {}
105
+ }
106
+ }
107
+ return false
108
+ }
109
+
110
+ const PLUGIN_DIR = (() => {
111
+ try {
112
+ return dirname(fileURLToPath(import.meta.url))
113
+ } catch {
114
+ return ""
115
+ }
116
+ })()
117
+
118
+ const IS_GLOBAL_COPY = PLUGIN_DIR !== "" && pluginIdFor(PLUGIN_DIR, resolveGlobalConfigDir()) === "ostacky-controller-global"
119
+ const PLUGIN_ID = IS_GLOBAL_COPY ? "ostacky-controller-global" : "ostacky-controller"
120
+
33
121
  // ─── Helpers ─────────────────────────────────────────────────────────────────
34
122
 
35
123
  function lookupOnPath(name: string): string | null {
@@ -48,9 +136,18 @@ function lookupOnPath(name: string): string | null {
48
136
  return null
49
137
  }
50
138
 
51
- function getStatePath(directory: string): string {
139
+ export function getStatePath(directory: string): string {
52
140
  if (process.env.OSTACKY_STATE_PATH) return process.env.OSTACKY_STATE_PATH
53
141
  const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
142
+ // El MCP puede estar configurado en el opencode.json GLOBAL: si el plugin solo
143
+ // mira el del proyecto, lee/escribe OTRO state file y todos los gates (PENDING,
144
+ // ownership, lastValidated) quedan ciegos. Mismo orden que assets/plugins/engram.ts.
145
+ try {
146
+ const globalDir = resolveGlobalConfigDir()
147
+ if (globalDir) {
148
+ candidates.push(join(globalDir, "opencode.json"), join(globalDir, "opencode.jsonc"))
149
+ }
150
+ } catch {}
54
151
  for (const cand of candidates) {
55
152
  try {
56
153
  const raw = readFileSync(cand, "utf-8")
@@ -73,6 +170,59 @@ function readState(directory: string): any | null {
73
170
  }
74
171
  }
75
172
 
173
+ /**
174
+ * Pendientes para recovery post-compaction. Fuente única del hook `compaction`
175
+ * y del hint de `chat.message`: lastHandoff → fallback <24h → expectedTasks.
176
+ * El checkpoint count-based del MCP solo escribe lastHandoff cada 3er
177
+ * complete_task, así que sin la tercera fuente el hint quedaba mudo en T1/T2.
178
+ */
179
+ export function recoveryPending(state: any, directory?: string): { pending: string[]; nextSteps: string[] } {
180
+ const isPending = (id: string) => !state?.tasks?.[id] || state.tasks[id].status !== "COMPLETED"
181
+ const fromExpected = () => (Array.isArray(state?.expectedTasks) ? state.expectedTasks.filter(isPending) : [])
182
+ let pending: string[] = Array.isArray(state?.lastHandoff?.pendingTasks)
183
+ ? state.lastHandoff.pendingTasks.filter(isPending)
184
+ : []
185
+ let nextSteps: string[] = Array.isArray(state?.lastHandoff?.nextSteps) ? state.lastHandoff.nextSteps : []
186
+ if (pending.length === 0 && directory) {
187
+ try {
188
+ const fallbackPath = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
189
+ if (existsSync(fallbackPath)) {
190
+ const data = JSON.parse(readFileSync(fallbackPath, "utf-8"))
191
+ if (data && Array.isArray(data.pendingTasks) && typeof data.ts === "number" && Date.now() - data.ts < 24 * 60 * 60 * 1000) {
192
+ pending = data.pendingTasks.filter(isPending)
193
+ if (Array.isArray(data.nextSteps) && data.nextSteps.length > 0) nextSteps = data.nextSteps
194
+ }
195
+ }
196
+ } catch {}
197
+ }
198
+ if (pending.length === 0) pending = fromExpected()
199
+ if (pending.length === 0) nextSteps = []
200
+ else if (nextSteps.length === 0) nextSteps = [`Continuar con ${pending.join(", ")}`]
201
+ return { pending, nextSteps }
202
+ }
203
+
204
+ /** Texto del hint de recovery (null si no hay pendientes). Puro: testeable sin hooks. */
205
+ export function recoveryHint(state: any, directory?: string): string | null {
206
+ if (!state || ["DONE", "INTERPRETATION_PENDING"].includes(state.state)) return null
207
+ const { pending } = recoveryPending(state, directory)
208
+ if (pending.length === 0) return null
209
+ return `\n\n[RECOVERY: te quedan ${pending.slice(0, 3).join(",")}${pending.length > 3 ? `, +${pending.length - 3} más` : ""} - usa get_handoff / mem_context para retomar]`
210
+ }
211
+
212
+ /** Payload del fallback de compaction, con los pendientes reales del state. */
213
+ export function compactionPayload(state: any, directory: string, sessionID?: string, contextSnippet = "") {
214
+ const { pending, nextSteps } = recoveryPending(state, directory)
215
+ return {
216
+ summary: state?.requestId
217
+ ? `Compaction fallback for request ${state.requestId} — ${pending.length} pendientes`
218
+ : `Compaction fallback for session ${sessionID ?? "unknown"} — plugin`,
219
+ nextSteps,
220
+ pendingTasks: pending,
221
+ ts: Date.now(),
222
+ contextSnippet,
223
+ }
224
+ }
225
+
76
226
  /** Sleep sincrónico acotado — los call sites del plugin son sync. */
77
227
  function sleepSync(ms: number): void {
78
228
  try {
@@ -97,10 +247,11 @@ function isPidAlive(pid: number): boolean {
97
247
 
98
248
  /**
99
249
  * Toma EL MISMO lock que el MCP (`<state>.lock.pid` con `wx`) para serializar
100
- * plugin ↔ MCP cross-proceso. Devuelve una función de release; si no se pudo
101
- * tomar, devuelve un no-op (la escritura sigue siendo segura por el merge).
250
+ * plugin ↔ MCP cross-proceso. Devuelve la función de release, o `null` si no se
251
+ * pudo tomar: el caller NO debe escribir sin lock — un `read` viejo + `rename`
252
+ * posterior puede revertir la transición que el MCP todavía no escribió.
102
253
  */
103
- function acquireStateLock(statePath: string): () => void {
254
+ function acquireStateLock(statePath: string): (() => void) | null {
104
255
  const pidPath = statePath + ".lock.pid"
105
256
  const tsPath = statePath + ".lock.timestamp"
106
257
  const staleWindow = 15_000
@@ -119,13 +270,21 @@ function acquireStateLock(statePath: string): () => void {
119
270
  } catch {}
120
271
  try {
121
272
  writeFileSync(pidPath, String(process.pid), { encoding: "utf-8", flag: "wx" })
122
- try { writeFileSync(tsPath, String(Date.now()), "utf-8") } catch {}
123
- acquired = true
273
+ try {
274
+ writeFileSync(tsPath, String(Date.now()), "utf-8")
275
+ acquired = true
276
+ } catch {
277
+ // Sin timestamp el lock es indistinguible de un huérfano y el MCP no
278
+ // puede liberarlo: queda pegado y ninguna escritura vuelve a entrar.
279
+ // Mejor soltarlo y reintentar.
280
+ try { unlinkSync(pidPath) } catch {}
281
+ sleepSync(5 + attempt * 10)
282
+ }
124
283
  } catch {
125
284
  sleepSync(5 + attempt * 10)
126
285
  }
127
286
  }
128
- if (!acquired) return () => {}
287
+ if (!acquired) return null
129
288
  return () => {
130
289
  try {
131
290
  if (readFileSync(pidPath, "utf-8").trim() !== String(process.pid)) return
@@ -139,6 +298,25 @@ function acquireStateLock(statePath: string): () => void {
139
298
 
140
299
  let persistSeq = 0
141
300
 
301
+ /**
302
+ * Patch que no se pudo escribir por falta de lock. Se aplica en la próxima
303
+ * escritura que sí lo consiga: perder un `lastValidated` rompería el gate de
304
+ * `complete_task`, pero escribir sin lock puede revertir un avance del MCP.
305
+ */
306
+ let pendingPatch: { patch: Record<string, unknown>; ts: number } | null = null
307
+ const PENDING_PATCH_TTL_MS = 60_000
308
+
309
+ function queuePatch(state: any, ownedKeys?: string[]): void {
310
+ if (!ownedKeys || ownedKeys.length === 0) return
311
+ const patch: Record<string, unknown> = {}
312
+ for (const k of ownedKeys) {
313
+ if (Object.prototype.hasOwnProperty.call(state, k)) patch[k] = state[k]
314
+ }
315
+ if (Object.keys(patch).length > 0) {
316
+ pendingPatch = { patch: { ...(pendingPatch?.patch ?? {}), ...patch }, ts: Date.now() }
317
+ }
318
+ }
319
+
142
320
  /**
143
321
  * Merge puro: parte del estado FRESCO de disco y le aplica SOLO las claves que
144
322
  * el plugin posee. Garantiza estructuralmente que una escritura del plugin no
@@ -199,6 +377,10 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
199
377
  try { mkdirSync(dir, { recursive: true }) } catch {}
200
378
 
201
379
  const release = acquireStateLock(p)
380
+ if (!release) {
381
+ queuePatch(state, ownedKeys)
382
+ return
383
+ }
202
384
  try {
203
385
  let payload: any = state
204
386
  if (ownedKeys && ownedKeys.length > 0) {
@@ -207,6 +389,16 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
207
389
  if (fresh && typeof fresh === "object" && !Array.isArray(fresh)) {
208
390
  payload = mergePluginOwnedState(fresh, state, ownedKeys)
209
391
  }
392
+ if (pendingPatch) {
393
+ // TTL: un patch viejo (p.ej. de otra request) no debe satisfacer gates
394
+ // actuales, como un `lastValidated` que ya no corresponde.
395
+ if (Date.now() - pendingPatch.ts > PENDING_PATCH_TTL_MS) {
396
+ pendingPatch = null
397
+ } else {
398
+ payload = { ...payload, ...pendingPatch.patch }
399
+ pendingPatch = null
400
+ }
401
+ }
210
402
  }
211
403
  let serialized = JSON.stringify(payload, null, 2)
212
404
  if (serialized.length > MAX_STATE_FILE_SIZE) {
@@ -234,6 +426,27 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
234
426
  }
235
427
  }
236
428
 
429
+ /**
430
+ * Proyección del state que expone `ostacky_get_state`. Incluye los decision IDs:
431
+ * sin ellos el agente no puede consumir la decisión pendiente sin volver a
432
+ * llamar a get_state por MCP (fuente del doble pregunta).
433
+ */
434
+ export function projectStateForTool(state: any): Record<string, unknown> {
435
+ if (!state) return { error: "no state", state: "UNKNOWN", revision: 0 }
436
+ return {
437
+ state: state.state,
438
+ revision: state.revision,
439
+ requestId: state.requestId,
440
+ changeId: state.changeId ?? null,
441
+ degraded: !!state.degraded,
442
+ level: state.level ?? null,
443
+ routeChoice: state.routeChoice ?? null,
444
+ routeDecisionId: state.routeDecisionId ?? null,
445
+ executionDecisionId: state.executionDecisionId ?? null,
446
+ executionMode: state.executionMode ?? null,
447
+ }
448
+ }
449
+
237
450
  function fastFingerprint(filePath: string): string | null {
238
451
  try {
239
452
  const s = statSync(filePath)
@@ -525,10 +738,15 @@ function extractPathsFromPatch(patchText: string): string[] {
525
738
  // Objeto literal directo (ver nota en imports): el server V2 acepta
526
739
  // `{ id, setup }` sin pasar por `Plugin.define()`.
527
740
  export default {
528
- id: "ostacky-controller",
741
+ id: PLUGIN_ID,
529
742
  async setup(ctx) {
530
743
  const directory = ctx.location.directory
531
744
 
745
+ // Precedencia local: si el proyecto tiene su propia instalación, esta copia
746
+ // global no registra hooks ni tools (OpenCode ya la cargó porque el config
747
+ // global se descubre primero; ver nota de scope arriba).
748
+ if (IS_GLOBAL_COPY && hasLocalPluginInstall(directory)) return
749
+
532
750
  // Track trivial flag per session to gate tools
533
751
  const trivialBySession = new Map<string, boolean>()
534
752
  // Track discovery hit per requestId to avoid blocking after hit
@@ -604,26 +822,8 @@ export default {
604
822
  // harden-compaction-resume: auto-inject recovery hint when pending (determinístico, no depende del modelo)
605
823
  if (!trivial && state && !["DONE", "INTERPRETATION_PENDING"].includes(state.state)) {
606
824
  try {
607
- let pending: string[] = Array.isArray(state.lastHandoff?.pendingTasks)
608
- ? state.lastHandoff.pendingTasks.filter((id: string) => !state.tasks?.[id] || state.tasks[id].status !== "COMPLETED")
609
- : []
610
- if (pending.length === 0) {
611
- try {
612
- const fallbackPath = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
613
- if (existsSync(fallbackPath)) {
614
- const raw = readFileSync(fallbackPath, "utf-8")
615
- const data = JSON.parse(raw)
616
- if (data && Array.isArray(data.pendingTasks) && typeof data.ts === "number" && Date.now() - data.ts < 24 * 60 * 60 * 1000) {
617
- const fbPending = data.pendingTasks.filter((id: string) => !state.tasks?.[id] || state.tasks[id].status !== "COMPLETED")
618
- if (fbPending.length > 0) pending = fbPending
619
- }
620
- }
621
- } catch {}
622
- }
623
- if (pending.length > 0) {
624
- const hint = `\n\n[RECOVERY: te quedan ${pending.slice(0, 3).join(",")}${pending.length > 3 ? `, +${pending.length - 3} más` : ""} - usa get_handoff / mem_context para retomar]`
625
- event.prompt.text = (event.prompt.text ?? "") + hint
626
- }
825
+ const hint = recoveryHint(state, directory)
826
+ if (hint) event.prompt.text = (event.prompt.text ?? "") + hint
627
827
  } catch {}
628
828
  }
629
829
  })
@@ -1085,11 +1285,7 @@ export default {
1085
1285
  name: "ostacky_get_state",
1086
1286
  description: "Get Ostacky controller state (plugin, no MCP needed)",
1087
1287
  input: emptySchema,
1088
- execute: async () => {
1089
- const state = readState(directory)
1090
- if (!state) return jsonContent({ error: "no state", state: "UNKNOWN", revision: 0 })
1091
- return jsonContent({ state: state.state, revision: state.revision, requestId: state.requestId, degraded: !!state.degraded, level: state.level, routeChoice: state.routeChoice })
1092
- },
1288
+ execute: async () => jsonContent(projectStateForTool(readState(directory))),
1093
1289
  })
1094
1290
  editor.add({
1095
1291
  name: "ostacky_get_audit",
@@ -1190,13 +1386,10 @@ export default {
1190
1386
  return JSON.stringify(event.messages.slice(0, 2)).slice(0, 800)
1191
1387
  } catch { return "" }
1192
1388
  })()
1193
- const payload = {
1194
- summary: `Compaction fallback for session ${event.sessionID ?? "unknown"} — plugin`,
1195
- nextSteps: [] as string[],
1196
- pendingTasks: [] as string[],
1197
- ts: Date.now(),
1198
- contextSnippet,
1199
- }
1389
+ // Los pendientes salen del state real, no de una lista vacía: sin esto
1390
+ // el hint de recovery nunca disparaba mid-run (checkpoint cada 3 tasks).
1391
+ const current = readState(directory)
1392
+ const payload = compactionPayload(current, directory, event.sessionID as string | undefined, contextSnippet)
1200
1393
  const tmp = `${fallbackPath}.tmp.${process.pid}`
1201
1394
  writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
1202
1395
  renameSync(tmp, fallbackPath)