ostacky 0.8.7 → 0.9.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.
- package/README.md +13 -13
- package/assets/agents/ostacky.md +7 -7
- package/assets/commands/install-stack.md +37 -29
- package/assets/docs/ostacky-reference.md +11 -3
- package/assets/mcp/ostacky-controller/controller-core.js +4 -2
- package/assets/mcp/ostacky-controller/index.js +248 -67
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/mcp/ostacky-controller/security.js +33 -2
- package/assets/plugins/controller-core.ts +4 -2
- package/assets/plugins/engram.ts +648 -646
- package/assets/plugins/ostacky-plugin.ts +287 -211
- package/assets/plugins/security.ts +118 -0
- package/assets/plugins/tiered.ts +46 -0
- package/assets/skills/dispatching-parallel-agents/SKILL.md +1 -0
- package/assets/skills/openspec-explore/SKILL.md +2 -1
- package/assets/skills/openspec-propose/SKILL.md +3 -1
- package/assets/skills/tdd/SKILL.md +2 -0
- package/dist/cli.js +290 -104
- package/manifest.json +32 -32
- package/package.json +3 -3
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Ostacky Controller — Plugin híbrido como alma
|
|
2
|
+
* Ostacky Controller — Plugin híbrido como alma (OpenCode V2)
|
|
3
3
|
*
|
|
4
4
|
* Fusiona assets/mcp/ostacky-controller/index.js + assets/plugins/ostacky-guard.ts
|
|
5
|
-
* Mantiene máquina de 13 estados en-process y aplica hard gates en tool
|
|
5
|
+
* Mantiene máquina de 13 estados en-process y aplica hard gates en tool hook execute.before.
|
|
6
6
|
* MCP queda thin solo para observabilidad (get_*).
|
|
7
7
|
*
|
|
8
|
-
* Single source security:
|
|
8
|
+
* Single source security: mirrors generados desde src/security.ts via scripts/sync-controller-core.ts
|
|
9
9
|
* Cache único: getDiscoverySnapshot es único entrypoint.
|
|
10
10
|
* Tiered cache-friendly: isTrivial sin reemplazar system[0], suffix hint + SKIP.
|
|
11
11
|
* CodeGraph preventivo: bloquea Read/Grep masivo sin Discovery hit.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import
|
|
14
|
+
import { Plugin } from "@opencode/plugin"
|
|
15
15
|
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync, readdirSync, unlinkSync } from "node:fs"
|
|
16
|
-
import { join, dirname, basename, resolve, relative } from "node:path"
|
|
17
|
-
import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from "
|
|
18
|
-
import { isTrivial } from "
|
|
16
|
+
import { join, dirname, basename, resolve, relative, delimiter } from "node:path"
|
|
17
|
+
import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from "./security.ts"
|
|
18
|
+
import { isTrivial } from "./tiered.ts"
|
|
19
19
|
import { STATES, TRANSITIONS, DEFAULT_STATE } from "./controller-core.ts"
|
|
20
20
|
|
|
21
21
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
@@ -23,13 +23,27 @@ import { STATES, TRANSITIONS, DEFAULT_STATE } from "./controller-core.ts"
|
|
|
23
23
|
const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024
|
|
24
24
|
const PING_INTERVAL_MS = 60_000
|
|
25
25
|
const IDLE_THRESHOLD_MS = 45_000
|
|
26
|
-
const PURPLE_TENUE = "\x1b[38;5;183m"
|
|
27
|
-
const PURPLE_RESET = "\x1b[0m"
|
|
28
26
|
|
|
29
27
|
// DEFAULT_STATE imported from controller-core.ts
|
|
30
28
|
|
|
31
29
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
32
30
|
|
|
31
|
+
function lookupOnPath(name: string): string | null {
|
|
32
|
+
try {
|
|
33
|
+
const pathEnv = process.env.PATH ?? ""
|
|
34
|
+
const suffix = process.platform === "win32" ? ".exe" : ""
|
|
35
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
36
|
+
if (!dir) continue
|
|
37
|
+
try {
|
|
38
|
+
const cand = join(dir, name + suffix)
|
|
39
|
+
if (existsSync(cand)) return cand
|
|
40
|
+
if (suffix && existsSync(join(dir, name))) return join(dir, name)
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
function getStatePath(directory: string): string {
|
|
34
48
|
if (process.env.OSTACKY_STATE_PATH) return process.env.OSTACKY_STATE_PATH
|
|
35
49
|
const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
|
|
@@ -38,6 +52,7 @@ function getStatePath(directory: string): string {
|
|
|
38
52
|
const raw = readFileSync(cand, "utf-8")
|
|
39
53
|
const json = JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""))
|
|
40
54
|
const envPath = (json as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
|
|
55
|
+
?? (json as any)?.mcp?.servers?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
|
|
41
56
|
if (typeof envPath === "string" && envPath) return envPath
|
|
42
57
|
} catch {}
|
|
43
58
|
}
|
|
@@ -107,7 +122,7 @@ function getDiscoveryCacheHit(directory: string): boolean {
|
|
|
107
122
|
try {
|
|
108
123
|
const cacheDir = join(directory, ".opencode", "cache", "codegraph")
|
|
109
124
|
if (!existsSync(cacheDir)) return false
|
|
110
|
-
const files = readdirSync(cacheDir).filter(f => f.startsWith("discovery-"))
|
|
125
|
+
const files = readdirSync(cacheDir).filter((f: string) => f.startsWith("discovery-"))
|
|
111
126
|
if (files.length === 0) return false
|
|
112
127
|
// check if any file is recent (<1h) and valid
|
|
113
128
|
const now = Date.now()
|
|
@@ -128,81 +143,101 @@ function isCodegraphAvailable(directory: string): boolean {
|
|
|
128
143
|
const bin = join(directory, ".opencode", "tools", "codegraph", "bin", "codegraph")
|
|
129
144
|
const binExe = bin + ".exe"
|
|
130
145
|
if (existsSync(bin) || existsSync(binExe)) return true
|
|
131
|
-
// also check
|
|
132
|
-
|
|
133
|
-
if (which) return true
|
|
146
|
+
// also check PATH
|
|
147
|
+
if (lookupOnPath("codegraph")) return true
|
|
134
148
|
return false
|
|
135
149
|
} catch { return false }
|
|
136
150
|
}
|
|
137
151
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// ─── Plugin ──────────────────────────────────────────────────────────────────
|
|
152
|
+
function jsonContent(value: unknown): { content: string } {
|
|
153
|
+
try {
|
|
154
|
+
return { content: JSON.stringify(value) ?? "null" }
|
|
155
|
+
} catch {
|
|
156
|
+
return { content: "null" }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
147
159
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Extrae paths de un patchText V2 (`*** Add File: <path>`, Update/Delete/Move).
|
|
162
|
+
* Move trae `old --> new`: se revisan ambos lados.
|
|
163
|
+
*/
|
|
164
|
+
function extractPathsFromPatch(patchText: string): string[] {
|
|
165
|
+
if (!patchText) return []
|
|
166
|
+
const paths: string[] = []
|
|
167
|
+
const re = /^\*\*\* (?:Add File|Update File|Delete File|Move File):\s*(.+)$/gm
|
|
168
|
+
let m: RegExpExecArray | null
|
|
169
|
+
while ((m = re.exec(patchText)) !== null) {
|
|
170
|
+
const rest = (m[1] ?? "").trim()
|
|
171
|
+
for (const part of rest.split(/\s+-->\s+|\s+->\s+/)) {
|
|
172
|
+
const p = part.trim().replace(/^["']|["']$/g, "")
|
|
173
|
+
if (p) paths.push(p)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return [...new Set(paths)]
|
|
177
|
+
}
|
|
154
178
|
|
|
155
|
-
|
|
156
|
-
let checkCount = 0
|
|
179
|
+
// ─── Plugin ──────────────────────────────────────────────────────────────────
|
|
157
180
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
export default Plugin.define({
|
|
182
|
+
id: "ostacky-controller",
|
|
183
|
+
async setup(ctx) {
|
|
184
|
+
const directory = ctx.location.directory
|
|
185
|
+
|
|
186
|
+
// Track trivial flag per session to gate tools
|
|
187
|
+
const trivialBySession = new Map<string, boolean>()
|
|
188
|
+
// Track discovery hit per requestId to avoid blocking after hit
|
|
189
|
+
const discoveryHitByRequest = new Map<string, boolean>()
|
|
190
|
+
// Track Engram hit per session to audit bypass (soft, no BLOCK)
|
|
191
|
+
const engramHitBySession = new Map<string, boolean>()
|
|
192
|
+
// ─── Heartbeat ping — evita sensación de trancado en tareas largas ──
|
|
193
|
+
let lastPingTs = 0
|
|
194
|
+
let pingInterval: ReturnType<typeof setInterval> | null = null
|
|
195
|
+
|
|
196
|
+
const patterns = (() => {
|
|
197
|
+
const raw = process.env.OSTACKY_SENSITIVE_PATTERNS
|
|
198
|
+
if (!raw) return SENSITIVE_DEFAULT
|
|
199
|
+
return raw.split(",").map((s: string) => s.trim()).filter(Boolean)
|
|
200
|
+
})()
|
|
201
|
+
|
|
202
|
+
let lastCheck: { revision: number; result: string } | null = null
|
|
203
|
+
let checkCount = 0
|
|
204
|
+
|
|
205
|
+
// Heartbeat ping — evita sensación de trancado en tareas largas
|
|
206
|
+
// (V2: sin canal TUI en ctx; se loguea al server log y se persiste lastPingTs)
|
|
207
|
+
if (!pingInterval) {
|
|
208
|
+
pingInterval = setInterval(() => {
|
|
209
|
+
try {
|
|
210
|
+
const s = readState(directory)
|
|
211
|
+
if (!s || !["EXECUTING_INLINE", "EXECUTING_SUBAGENTS", "SYNC"].includes(s.state)) return
|
|
212
|
+
const now = Date.now()
|
|
213
|
+
const lastHeartbeat = s.lastHeartbeat || s.ts || now
|
|
214
|
+
const idle = now - lastHeartbeat
|
|
215
|
+
if (idle < IDLE_THRESHOLD_MS) return
|
|
216
|
+
if (now - lastPingTs < PING_INTERVAL_MS) return
|
|
217
|
+
lastPingTs = now
|
|
218
|
+
const completed = Object.values(s.tasks || {}).filter((t: any) => t.status === "COMPLETED").length
|
|
219
|
+
const total = s.expectedTaskCount ?? s.expectedTasks?.length ?? "?"
|
|
220
|
+
const pending = Array.isArray(s.expectedTasks) ? s.expectedTasks.filter((id: string) => !s.tasks?.[id] || s.tasks[id].status !== "COMPLETED").length : "?"
|
|
221
|
+
const msg = `[ostacky] Sigo trabajando — ${s.state} • ${completed}/${total} (${pending} pendientes) • hace ${Math.round(idle/1000)}s sin output`
|
|
222
|
+
try { console.log(msg) } catch {}
|
|
223
|
+
try { const st = readState(directory); if (st) { (st as any).lastPingTs = now; persistState(directory, st) } } catch {}
|
|
224
|
+
} catch {}
|
|
225
|
+
}, 30_000)
|
|
226
|
+
if (pingInterval && typeof (pingInterval as any).unref === 'function') (pingInterval as any).unref()
|
|
227
|
+
}
|
|
181
228
|
|
|
182
|
-
return {
|
|
183
229
|
// ── Tiered: suffix hint on user message (cache-friendly, no system replace) ──
|
|
184
|
-
|
|
185
|
-
const sessionId: string =
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
|| output.message?.summary?.title || ""
|
|
189
|
-
const state = readState(ctx.directory)
|
|
230
|
+
await ctx.session.hook("prompt", (event) => {
|
|
231
|
+
const sessionId: string = event.sessionID ?? "default"
|
|
232
|
+
const text = (event.prompt.text ?? "").trim()
|
|
233
|
+
const state = readState(directory)
|
|
190
234
|
const currentState = state?.state ?? "DONE"
|
|
191
235
|
const trivial = isTrivial(text, currentState)
|
|
192
236
|
trivialBySession.set(sessionId, trivial)
|
|
193
237
|
if (trivial) {
|
|
194
|
-
// Preserve system
|
|
238
|
+
// Preserve system FULL cacheable, add suffix hint to user prompt text
|
|
195
239
|
const hint = "\n\n[PLUGIN HINT: Saludo trivial — responde breve sin tools. No hagas Discovery.]"
|
|
196
|
-
|
|
197
|
-
const last = output.parts[output.parts.length - 1]
|
|
198
|
-
if (last.type === "text") last.text = (last.text ?? "") + hint
|
|
199
|
-
else output.parts.push({ type: "text", text: hint })
|
|
200
|
-
} else if (output.message) {
|
|
201
|
-
output.parts = [{ type: "text", text: text + hint }]
|
|
202
|
-
}
|
|
203
|
-
} else {
|
|
204
|
-
// Also handle TIER1 hint for small tasks without replacing system
|
|
205
|
-
// Intent detection for downgradeable 0/0+1 is done in record_discovery router, not here
|
|
240
|
+
event.prompt.text = (event.prompt.text ?? "") + hint
|
|
206
241
|
}
|
|
207
242
|
// harden-compaction-resume: auto-inject recovery hint when pending (determinístico, no depende del modelo)
|
|
208
243
|
if (!trivial && state && !["DONE", "INTERPRETATION_PENDING"].includes(state.state)) {
|
|
@@ -212,7 +247,7 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
212
247
|
: []
|
|
213
248
|
if (pending.length === 0) {
|
|
214
249
|
try {
|
|
215
|
-
const fallbackPath = join(dirname(getStatePath(
|
|
250
|
+
const fallbackPath = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
|
|
216
251
|
if (existsSync(fallbackPath)) {
|
|
217
252
|
const raw = readFileSync(fallbackPath, "utf-8")
|
|
218
253
|
const data = JSON.parse(raw)
|
|
@@ -225,27 +260,21 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
225
260
|
}
|
|
226
261
|
if (pending.length > 0) {
|
|
227
262
|
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]`
|
|
228
|
-
|
|
229
|
-
const last = output.parts[output.parts.length - 1]
|
|
230
|
-
if (last.type === "text") last.text = (last.text ?? "") + hint
|
|
231
|
-
else output.parts.push({ type: "text", text: hint })
|
|
232
|
-
} else {
|
|
233
|
-
output.parts = [{ type: "text", text: text + hint }]
|
|
234
|
-
}
|
|
263
|
+
event.prompt.text = (event.prompt.text ?? "") + hint
|
|
235
264
|
}
|
|
236
265
|
} catch {}
|
|
237
266
|
}
|
|
238
|
-
}
|
|
267
|
+
})
|
|
239
268
|
|
|
240
269
|
// ── Hard gates before any tool ──
|
|
241
|
-
|
|
242
|
-
const tool: string =
|
|
243
|
-
const args: any = (input
|
|
244
|
-
const sessionId: string = (
|
|
270
|
+
await ctx.tool.hook("execute.before", (event) => {
|
|
271
|
+
const tool: string = event.tool as string
|
|
272
|
+
const args: any = (event.input ?? {}) as any
|
|
273
|
+
const sessionId: string = (event.sessionID ?? "default") as string
|
|
245
274
|
|
|
246
275
|
// ── 0) Trivial greeting blocks for expensive tools (SKIP, not BLOCKED) ──
|
|
247
276
|
const isTrivialSession = trivialBySession.get(sessionId) ?? false
|
|
248
|
-
const stateForTrivial = readState(
|
|
277
|
+
const stateForTrivial = readState(directory)
|
|
249
278
|
if (isTrivialSession && stateForTrivial?.state === "DONE") {
|
|
250
279
|
const blockedForTrivial = [
|
|
251
280
|
"engram_mem_context", "mem_context",
|
|
@@ -259,7 +288,7 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
259
288
|
|
|
260
289
|
// ── 1) PENDING hard gate (0 tokens) ──
|
|
261
290
|
checkCount++
|
|
262
|
-
const freshState = readState(
|
|
291
|
+
const freshState = readState(directory)
|
|
263
292
|
const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
|
|
264
293
|
if (freshState && pendingStates.includes(freshState.state)) {
|
|
265
294
|
// cache ALLOW per revision, BLOCKED never cached
|
|
@@ -279,19 +308,39 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
279
308
|
lastCheck = { revision: freshState.revision, result: "ALLOW" }
|
|
280
309
|
}
|
|
281
310
|
|
|
311
|
+
// ── 1.6) Engram soft gate (auditable, no BLOCK) — refuerza SHALL 1 de ostacky.md ──
|
|
312
|
+
// Si propone/cambia nivel sin haber consultado Engram, cuenta bypass (exime trivial+DONE)
|
|
313
|
+
const isEngramRelevantTool = tool.includes("record_discovery") || (tool.includes("openspec") && (tool.includes("propose") || (typeof args?.filePath === "string" && args.filePath.includes("openspec/changes")) || (typeof args?.path === "string" && args.path.includes("openspec/changes"))))
|
|
314
|
+
if (isEngramRelevantTool) {
|
|
315
|
+
const hasEngramHit = engramHitBySession.get(sessionId) ?? false
|
|
316
|
+
const isTrivialForEngram = trivialBySession.get(sessionId) ?? false
|
|
317
|
+
const stateForEngram = freshState
|
|
318
|
+
const shouldAudit = !hasEngramHit && !isTrivialForEngram && stateForEngram && ["DISCOVERY", "ROUTE_DECISION_PENDING", "SPECIFICATION", "INTERPRETATION_PENDING"].includes(stateForEngram.state)
|
|
319
|
+
if (shouldAudit) {
|
|
320
|
+
try {
|
|
321
|
+
const st = readState(directory)
|
|
322
|
+
if (st) {
|
|
323
|
+
st.engramBypassCount = (st.engramBypassCount || 0) + 1
|
|
324
|
+
// no persist cada vez si es muy frecuente: single-writer ya persiste en after, pero acá persistimos directo para auditoría
|
|
325
|
+
persistState(directory, st)
|
|
326
|
+
}
|
|
327
|
+
} catch {}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
282
331
|
// ── 1.5) Router determinista: openspec-propose bloquea si 1+ no-downgradeable sin Alternatives ──
|
|
283
332
|
const isOpenspecPropose = tool.includes("openspec") && (tool.includes("propose") || args?.filePath?.includes("openspec/changes") || args?.path?.includes("openspec/changes"))
|
|
284
333
|
if (isOpenspecPropose) {
|
|
285
|
-
const s = readState(
|
|
334
|
+
const s = readState(directory)
|
|
286
335
|
if (s?.level === "1+" && s?._routerNeedsAlternatives) {
|
|
287
336
|
// check if design.md has Alternatives
|
|
288
337
|
try {
|
|
289
338
|
const changeId = args?.changeId || s?.changeId || ""
|
|
290
339
|
let designPath: string | null = null
|
|
291
|
-
if (changeId) designPath = join(
|
|
340
|
+
if (changeId) designPath = join(directory, "openspec", "changes", changeId, "design.md")
|
|
292
341
|
else {
|
|
293
342
|
// try to find any change dir with _routerNeedsAlternatives
|
|
294
|
-
const changesDir = join(
|
|
343
|
+
const changesDir = join(directory, "openspec", "changes")
|
|
295
344
|
if (existsSync(changesDir)) {
|
|
296
345
|
for (const entry of readdirSync(changesDir)) {
|
|
297
346
|
const p = join(changesDir, entry, "design.md")
|
|
@@ -328,8 +377,8 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
328
377
|
// Grep on *.md should not be blocked
|
|
329
378
|
const isLiteralGrep = tool === "grep" && (args?.include?.endsWith(".md") || args?.include?.endsWith(".json"))
|
|
330
379
|
if (isCodeFile && !isLiteralGrep && !isSpecTasksPath) {
|
|
331
|
-
const hasDiscoveryHit = discoveryHitByRequest.get(sessionId) ?? getDiscoveryCacheHit(
|
|
332
|
-
const codegraphOk = isCodegraphAvailable(
|
|
380
|
+
const hasDiscoveryHit = discoveryHitByRequest.get(sessionId) ?? getDiscoveryCacheHit(directory)
|
|
381
|
+
const codegraphOk = isCodegraphAvailable(directory)
|
|
333
382
|
if (codegraphOk && !hasDiscoveryHit) {
|
|
334
383
|
// Allow if file is not indexable or trivial?
|
|
335
384
|
// Block with suggestion
|
|
@@ -339,7 +388,7 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
339
388
|
}
|
|
340
389
|
|
|
341
390
|
// ── 3) Sensitive gate (hard, even in degraded) ──
|
|
342
|
-
if (tool === "
|
|
391
|
+
if (tool === "shell") {
|
|
343
392
|
const cmd: string = args?.command || args?.cmd || ""
|
|
344
393
|
if (typeof cmd === "string" && cmd) {
|
|
345
394
|
const normalized = cmd.replace(/["'`]/g, "").replace(/\\/g, "")
|
|
@@ -349,27 +398,27 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
349
398
|
if (hasSensitivePattern || sensitivePaths.length > 0) {
|
|
350
399
|
if (sensitivePaths.length > 0) {
|
|
351
400
|
for (const p of sensitivePaths) {
|
|
352
|
-
const s = readState(
|
|
353
|
-
const candidates = [p, resolve(
|
|
401
|
+
const s = readState(directory)
|
|
402
|
+
const candidates = [p, resolve(directory, p), join(directory, p)]
|
|
354
403
|
const allowed = candidates.some((c) => s?.allowedFiles?.[c] || s?.allowedFiles?.[p])
|
|
355
|
-
const baseAllowed = s?.allowedFiles?.[p] || s?.allowedFiles?.[basename(p)] || s?.allowedFiles?.[resolve(
|
|
404
|
+
const baseAllowed = s?.allowedFiles?.[p] || s?.allowedFiles?.[basename(p)] || s?.allowedFiles?.[resolve(directory, p)]
|
|
356
405
|
if (!allowed && !baseAllowed) {
|
|
357
406
|
const denied = s?.deniedFiles?.[p] || s?.deniedFiles?.[basename(p)]
|
|
358
|
-
if (denied) throw new Error(`BLOCKED:
|
|
359
|
-
throw new Error(`BLOCKED:
|
|
407
|
+
if (denied) throw new Error(`BLOCKED: shell contiene acceso sensible (${p}) (previously denied) — Llamá check_file_access con reason antes.`)
|
|
408
|
+
throw new Error(`BLOCKED: shell contiene acceso sensible (${p}). Llamá check_file_access con reason antes.`)
|
|
360
409
|
}
|
|
361
410
|
}
|
|
362
411
|
} else if (hasSensitivePattern) {
|
|
363
|
-
const s = readState(
|
|
412
|
+
const s = readState(directory)
|
|
364
413
|
const hasAllowed = Object.keys(s?.allowedFiles || {}).some((k) => isSensitive(k, patterns))
|
|
365
|
-
if (!hasAllowed) throw new Error(`BLOCKED:
|
|
414
|
+
if (!hasAllowed) throw new Error(`BLOCKED: shell contiene acceso sensible (.env). Llamá check_file_access con reason antes.`)
|
|
366
415
|
}
|
|
367
416
|
}
|
|
368
417
|
}
|
|
369
418
|
}
|
|
370
419
|
|
|
371
|
-
// ── 3.5)
|
|
372
|
-
if (tool === "
|
|
420
|
+
// ── 3.5) Shell file-mutation gate (harden-task-integrity: shell mutante requiere validate_edit) ──
|
|
421
|
+
if (tool === "shell" && freshState && ["EXECUTING_INLINE", "EXECUTING_SUBAGENTS"].includes(freshState.state)) {
|
|
373
422
|
const cmdForMutation: string = args?.command || args?.cmd || ""
|
|
374
423
|
const isMutating = /[>]{1,2}\s*\S+|\bsed\b[^|;]*-i|\btruncate\b|\btee\b|\bcp\s+|\bmv\s+|python.*open.*w/.test(cmdForMutation)
|
|
375
424
|
if (isMutating) {
|
|
@@ -378,24 +427,24 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
378
427
|
if (!p) continue
|
|
379
428
|
const isProjectCode = p.includes("src/") || p.includes("assets/") || /\.(ts|js|tsx|jsx|mts|cts)$/i.test(p)
|
|
380
429
|
if (!isProjectCode) continue
|
|
381
|
-
if (!isPathInsideProject(p,
|
|
382
|
-
const abs = resolve(
|
|
430
|
+
if (!isPathInsideProject(p, directory)) continue
|
|
431
|
+
const abs = resolve(directory, p)
|
|
383
432
|
const exists = existsSync(abs)
|
|
384
433
|
const isNewFile = !exists && !freshState.fileFingerprints?.[p] && !Object.values(freshState.tasks || {}).some((t: any) => t.filePath === p)
|
|
385
434
|
if (isNewFile) continue
|
|
386
435
|
const lv = freshState.lastValidated
|
|
387
436
|
if (!lv || lv.filePath !== p) {
|
|
388
|
-
throw new Error(`BLOCKED: file mutation requires validate_edit/expectedTask for ${p} — usa validate_edit antes de
|
|
437
|
+
throw new Error(`BLOCKED: file mutation requires validate_edit/expectedTask for ${p} — usa validate_edit antes de shell mutante`)
|
|
389
438
|
}
|
|
390
439
|
}
|
|
391
440
|
}
|
|
392
441
|
}
|
|
393
442
|
|
|
394
443
|
let filePath: string | undefined
|
|
395
|
-
if (tool === "read" || tool === "read_mcp_resource" || tool === "grep" || tool === "glob" || tool === "
|
|
444
|
+
if (tool === "read" || tool === "read_mcp_resource" || tool === "grep" || tool === "glob" || tool === "edit" || tool === "write") {
|
|
396
445
|
filePath = args?.filePath || args?.path || args?.pattern || args?.uri || ""
|
|
397
446
|
if (tool === "grep" && args?.include) filePath = args.include
|
|
398
|
-
if (tool === "
|
|
447
|
+
if (tool === "edit" || tool === "write") filePath = args?.filePath || args?.path || ""
|
|
399
448
|
if (tool === "read_mcp_resource" && typeof args?.uri === "string") {
|
|
400
449
|
try {
|
|
401
450
|
const u = args.uri as string
|
|
@@ -405,17 +454,30 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
405
454
|
}
|
|
406
455
|
}
|
|
407
456
|
if (filePath && isSensitive(filePath, patterns)) {
|
|
408
|
-
const s = readState(
|
|
409
|
-
const allowed = s?.allowedFiles?.[filePath] || s?.allowedFiles?.[resolve(
|
|
457
|
+
const s = readState(directory)
|
|
458
|
+
const allowed = s?.allowedFiles?.[filePath] || s?.allowedFiles?.[resolve(directory, filePath)] || s?.allowedFiles?.[basename(filePath)]
|
|
410
459
|
if (!allowed) {
|
|
411
460
|
const denied = s?.deniedFiles?.[filePath] || s?.deniedFiles?.[basename(filePath)]
|
|
412
461
|
if (denied) throw new Error(`BLOCKED: File ${filePath} requires check_file_access (previously denied)`)
|
|
413
462
|
throw new Error(`BLOCKED: File ${filePath} requires check_file_access`)
|
|
414
463
|
}
|
|
415
464
|
}
|
|
465
|
+
// patch usa patchText (no filePath): se chequean los paths mencionados
|
|
466
|
+
if (tool === "patch" && typeof args?.patchText === "string" && args.patchText) {
|
|
467
|
+
for (const patchPath of extractPathsFromPatch(args.patchText)) {
|
|
468
|
+
if (!patchPath || !isSensitive(patchPath, patterns)) continue
|
|
469
|
+
const s = readState(directory)
|
|
470
|
+
const allowed = s?.allowedFiles?.[patchPath] || s?.allowedFiles?.[resolve(directory, patchPath)] || s?.allowedFiles?.[basename(patchPath)]
|
|
471
|
+
if (!allowed) {
|
|
472
|
+
const denied = s?.deniedFiles?.[patchPath] || s?.deniedFiles?.[basename(patchPath)]
|
|
473
|
+
if (denied) throw new Error(`BLOCKED: File ${patchPath} requires check_file_access (previously denied)`)
|
|
474
|
+
throw new Error(`BLOCKED: File ${patchPath} requires check_file_access`)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
416
478
|
|
|
417
|
-
// ── 4) validate_edit in-process for write
|
|
418
|
-
if (tool === "
|
|
479
|
+
// ── 4) validate_edit in-process for edit/write (V2 keeps both tools; patch usa patchText y queda fuera — follow-up) ──
|
|
480
|
+
if (tool === "edit" || tool === "write") {
|
|
419
481
|
const oldString: string = args?.oldString ?? ""
|
|
420
482
|
const newString: string = args?.newString ?? args?.content ?? ""
|
|
421
483
|
const targetPath: string = args?.filePath || args?.path || ""
|
|
@@ -423,31 +485,30 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
423
485
|
if (oldString === newString) {
|
|
424
486
|
throw new Error(`CONFLICT: oldString === newString`)
|
|
425
487
|
}
|
|
426
|
-
if (!isPathInsideProject(targetPath,
|
|
488
|
+
if (!isPathInsideProject(targetPath, directory)) {
|
|
427
489
|
throw new Error(`BLOCKED: path outside project: ${targetPath}`)
|
|
428
490
|
}
|
|
429
491
|
// If oldString is hash:<fp> handle stale check
|
|
430
492
|
if (oldString.startsWith("hash:")) {
|
|
431
493
|
const claimed = oldString.slice(5)
|
|
432
|
-
const currentFp = fastFingerprint(resolve(
|
|
433
|
-
const s = readState(
|
|
494
|
+
const currentFp = fastFingerprint(resolve(directory, targetPath))
|
|
495
|
+
const s = readState(directory)
|
|
434
496
|
const last = s?.lastValidated
|
|
435
497
|
if (claimed !== currentFp || (last && last.filePath === targetPath && last.hash !== currentFp)) {
|
|
436
498
|
// mark stale attempt
|
|
437
499
|
try {
|
|
438
|
-
const st = readState(
|
|
500
|
+
const st = readState(directory) || { ...DEFAULT_STATE }
|
|
439
501
|
st.staleContentAttempts = (st.staleContentAttempts || 0) + 1
|
|
440
|
-
persistState(
|
|
502
|
+
persistState(directory, st)
|
|
441
503
|
} catch {}
|
|
442
504
|
throw new Error(`CONFLICT: stale fingerprint for ${targetPath}`)
|
|
443
505
|
}
|
|
444
506
|
} else if (oldString.length > 0) {
|
|
445
507
|
// Check fresh content has exactly one occurrence
|
|
446
508
|
try {
|
|
447
|
-
const fullPath = resolve(
|
|
509
|
+
const fullPath = resolve(directory, targetPath)
|
|
448
510
|
if (existsSync(fullPath)) {
|
|
449
511
|
const content = readFileSync(fullPath, "utf-8")
|
|
450
|
-
const escaped = oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
451
512
|
// count occurrences literally, not regex
|
|
452
513
|
let count = 0
|
|
453
514
|
let idx = 0
|
|
@@ -466,173 +527,188 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
466
527
|
}
|
|
467
528
|
// Record lastValidated for future hash checks
|
|
468
529
|
try {
|
|
469
|
-
const st = readState(
|
|
530
|
+
const st = readState(directory)
|
|
470
531
|
if (st) {
|
|
471
|
-
const fp = fastFingerprint(resolve(
|
|
532
|
+
const fp = fastFingerprint(resolve(directory, targetPath))
|
|
472
533
|
st.lastValidated = { filePath: targetPath, hash: fp, ts: Date.now() }
|
|
473
534
|
}
|
|
474
535
|
} catch {}
|
|
475
536
|
}
|
|
476
537
|
}
|
|
477
|
-
}
|
|
538
|
+
})
|
|
478
539
|
|
|
479
|
-
|
|
540
|
+
await ctx.tool.hook("execute.after", (event) => {
|
|
480
541
|
// Track discovery hit to allow subsequent Reads
|
|
481
|
-
const tool: string =
|
|
482
|
-
const sessionId: string = (
|
|
542
|
+
const tool: string = event.tool as string
|
|
543
|
+
const sessionId: string = (event.sessionID ?? "default") as string
|
|
544
|
+
const resultText = event.status === "completed" && event.result ? JSON.stringify(event.result) : ""
|
|
483
545
|
if (tool.includes("getDiscoverySnapshot") || tool.includes("get_discovery_snapshot")) {
|
|
484
546
|
// if output indicates hit, mark it
|
|
485
547
|
try {
|
|
486
|
-
const text =
|
|
548
|
+
const text = resultText
|
|
487
549
|
if (text && !text.includes("null") && text.length > 10) {
|
|
488
550
|
discoveryHitByRequest.set(sessionId, true)
|
|
489
551
|
}
|
|
552
|
+
// Engram hit via discovery snapshot (contiene engramHits)
|
|
553
|
+
if (text && text.includes("engramHits")) {
|
|
554
|
+
engramHitBySession.set(sessionId, true)
|
|
555
|
+
}
|
|
490
556
|
} catch {}
|
|
491
557
|
}
|
|
492
|
-
//
|
|
493
|
-
if (tool.includes("
|
|
494
|
-
|
|
495
|
-
const s = readState((input as any).ctx?.directory ?? "")
|
|
496
|
-
} catch {}
|
|
558
|
+
// Track Engram hit (mem_search / mem_context / dedup) para soft gate 1.6
|
|
559
|
+
if (tool.includes("engram_mem_search") || tool.includes("engram_mem_context") || tool.includes("mem_search") || tool.includes("mem_context") || tool.includes("getEngramDedup") || tool.includes("engram_mem_")) {
|
|
560
|
+
engramHitBySession.set(sessionId, true)
|
|
497
561
|
}
|
|
498
|
-
// Heartbeat
|
|
499
|
-
let lastHeartbeatMem = 0
|
|
562
|
+
// Heartbeat: single-writer — plugin memo, no persist cada tool (D10)
|
|
500
563
|
try {
|
|
501
|
-
const
|
|
502
|
-
const s = readState(dir)
|
|
564
|
+
const s = readState(directory)
|
|
503
565
|
if (s) {
|
|
504
566
|
const now = Date.now()
|
|
505
|
-
lastHeartbeatMem = now
|
|
506
567
|
// Single-writer: solo persiste si OSTACKY_PLUGIN_PERSIST=1 o idle>30s o state cambió
|
|
507
568
|
const shouldPersist = process.env.OSTACKY_PLUGIN_PERSIST === "1" || (now - (s.lastHeartbeat || 0) > 30000)
|
|
508
569
|
if (shouldPersist) {
|
|
509
570
|
s.lastHeartbeat = now
|
|
510
|
-
try { persistState(
|
|
511
|
-
}
|
|
512
|
-
if (["EXECUTING_INLINE", "EXECUTING_SUBAGENTS", "SYNC"].includes(s.state) && output && typeof output.title === "string" && output.title && !output.title.includes("🟣")) {
|
|
513
|
-
output.title = `🟣 ${PURPLE_TENUE}[OSTACKY]${PURPLE_RESET} ${output.title}`
|
|
571
|
+
try { persistState(directory, s) } catch {}
|
|
514
572
|
}
|
|
515
573
|
}
|
|
516
574
|
} catch {}
|
|
517
|
-
}
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
const emptySchema = {
|
|
578
|
+
type: "object",
|
|
579
|
+
properties: {},
|
|
580
|
+
required: [],
|
|
581
|
+
additionalProperties: false,
|
|
582
|
+
} as const
|
|
518
583
|
|
|
519
584
|
// ── Observable tools (MCP thin replacement) ──
|
|
520
|
-
tool
|
|
521
|
-
|
|
585
|
+
await ctx.tool.transform((editor) => {
|
|
586
|
+
editor.add({
|
|
587
|
+
name: "ostacky_get_state",
|
|
522
588
|
description: "Get Ostacky controller state (plugin, no MCP needed)",
|
|
523
|
-
|
|
524
|
-
execute: async (
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
return { state: state.state, revision: state.revision, requestId: state.requestId, degraded: !!state.degraded, level: state.level, routeChoice: state.routeChoice }
|
|
589
|
+
input: emptySchema,
|
|
590
|
+
execute: async () => {
|
|
591
|
+
const state = readState(directory)
|
|
592
|
+
if (!state) return jsonContent({ error: "no state", state: "UNKNOWN", revision: 0 })
|
|
593
|
+
return jsonContent({ state: state.state, revision: state.revision, requestId: state.requestId, degraded: !!state.degraded, level: state.level, routeChoice: state.routeChoice })
|
|
529
594
|
},
|
|
530
|
-
}
|
|
531
|
-
|
|
595
|
+
})
|
|
596
|
+
editor.add({
|
|
597
|
+
name: "ostacky_get_audit",
|
|
532
598
|
description: "Get audit trail (plugin)",
|
|
533
|
-
|
|
534
|
-
execute: async (
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
return { audit: state?.audit ?? [], revision: state?.revision ?? 0 }
|
|
599
|
+
input: emptySchema,
|
|
600
|
+
execute: async () => {
|
|
601
|
+
const state = readState(directory)
|
|
602
|
+
return jsonContent({ audit: state?.audit ?? [], revision: state?.revision ?? 0 })
|
|
538
603
|
},
|
|
539
|
-
}
|
|
540
|
-
|
|
604
|
+
})
|
|
605
|
+
editor.add({
|
|
606
|
+
name: "ostacky_get_metrics",
|
|
541
607
|
description: "Get controller metrics (plugin)",
|
|
542
|
-
|
|
543
|
-
execute: async (
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
return {
|
|
608
|
+
input: emptySchema,
|
|
609
|
+
execute: async () => {
|
|
610
|
+
const state = readState(directory)
|
|
611
|
+
return jsonContent({
|
|
547
612
|
cacheHitCount: state?.cacheHitCount ?? 0,
|
|
548
613
|
discoveryCacheHitCount: state?.discoveryCacheHitCount ?? 0,
|
|
549
614
|
tokenSavingEstimate: state?.tokenSavingEstimate ?? 0,
|
|
550
615
|
stateCheckCount: state?.stateCheckCount ?? 0,
|
|
551
616
|
codegraphBypassCount: state?.codegraphBypassCount ?? 0,
|
|
617
|
+
engramBypassCount: state?.engramBypassCount ?? 0,
|
|
552
618
|
revision: state?.revision ?? 0,
|
|
553
|
-
}
|
|
619
|
+
})
|
|
554
620
|
},
|
|
555
|
-
}
|
|
556
|
-
|
|
621
|
+
})
|
|
622
|
+
editor.add({
|
|
623
|
+
name: "ostacky_get_handoff",
|
|
557
624
|
description: "Get last handoff (plugin)",
|
|
558
|
-
|
|
559
|
-
execute: async (
|
|
560
|
-
const
|
|
561
|
-
const state = readState(dir)
|
|
625
|
+
input: emptySchema,
|
|
626
|
+
execute: async () => {
|
|
627
|
+
const state = readState(directory)
|
|
562
628
|
// also check compaction fallback
|
|
563
629
|
try {
|
|
564
|
-
const fallback = join(dirname(getStatePath(
|
|
630
|
+
const fallback = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
|
|
565
631
|
if (existsSync(fallback)) {
|
|
566
632
|
const raw = readFileSync(fallback, "utf-8")
|
|
567
633
|
const data = JSON.parse(raw)
|
|
568
|
-
if (data && !state?.lastHandoff) return data
|
|
634
|
+
if (data && !state?.lastHandoff) return jsonContent(data)
|
|
569
635
|
}
|
|
570
636
|
} catch {}
|
|
571
|
-
return state?.lastHandoff ?? null
|
|
637
|
+
return jsonContent(state?.lastHandoff ?? null)
|
|
572
638
|
},
|
|
573
|
-
}
|
|
574
|
-
|
|
639
|
+
})
|
|
640
|
+
editor.add({
|
|
641
|
+
name: "ostacky_get_available_transitions",
|
|
575
642
|
description: "Get available transitions from current state",
|
|
576
|
-
|
|
577
|
-
execute: async (
|
|
578
|
-
const
|
|
579
|
-
const state = readState(dir)
|
|
643
|
+
input: emptySchema,
|
|
644
|
+
execute: async () => {
|
|
645
|
+
const state = readState(directory)
|
|
580
646
|
const cur = state?.state ?? "INTERPRETATION_PENDING"
|
|
581
647
|
const trans = TRANSITIONS[cur] ?? []
|
|
582
|
-
return { currentState: cur, transitions: trans }
|
|
648
|
+
return jsonContent({ currentState: cur, transitions: trans })
|
|
583
649
|
},
|
|
584
|
-
}
|
|
650
|
+
})
|
|
585
651
|
// Compatibility for old MCP calls that expect deprecated response
|
|
586
|
-
|
|
652
|
+
editor.add({
|
|
653
|
+
name: "ostacky_check_pending_state",
|
|
587
654
|
description: "[deprecated] plugin enforces — use tool before hook",
|
|
588
|
-
|
|
589
|
-
execute: async (
|
|
590
|
-
return { deprecated: true, hint: "plugin enforces", allowed: true }
|
|
655
|
+
input: emptySchema,
|
|
656
|
+
execute: async () => {
|
|
657
|
+
return jsonContent({ deprecated: true, hint: "plugin enforces", allowed: true })
|
|
591
658
|
},
|
|
592
|
-
}
|
|
593
|
-
|
|
659
|
+
})
|
|
660
|
+
editor.add({
|
|
661
|
+
name: "ostacky_validate_edit",
|
|
594
662
|
description: "[deprecated] plugin enforces validate_edit in-process",
|
|
595
|
-
|
|
596
|
-
execute: async (
|
|
597
|
-
return { deprecated: true, hint: "plugin enforces" }
|
|
663
|
+
input: emptySchema,
|
|
664
|
+
execute: async () => {
|
|
665
|
+
return jsonContent({ deprecated: true, hint: "plugin enforces" })
|
|
598
666
|
},
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
if (
|
|
606
|
-
|
|
607
|
-
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
const eventController = new AbortController()
|
|
671
|
+
void (async () => {
|
|
672
|
+
for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
|
|
673
|
+
if (event.type === "session.deleted") {
|
|
674
|
+
const sid = event.data.sessionID as string
|
|
675
|
+
if (sid) {
|
|
676
|
+
trivialBySession.delete(sid)
|
|
677
|
+
discoveryHitByRequest.delete(sid)
|
|
678
|
+
engramHitBySession.delete(sid)
|
|
679
|
+
}
|
|
608
680
|
}
|
|
609
681
|
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
dispose: async () => {
|
|
613
|
-
try { if (pingInterval) clearInterval(pingInterval) } catch {}
|
|
614
|
-
pingInterval = null
|
|
615
|
-
},
|
|
682
|
+
})()
|
|
616
683
|
|
|
617
|
-
|
|
684
|
+
await ctx.session.hook("compaction", (event) => {
|
|
618
685
|
try {
|
|
619
|
-
const statePath = getStatePath(
|
|
686
|
+
const statePath = getStatePath(directory)
|
|
620
687
|
const dir = dirname(statePath)
|
|
621
688
|
try { mkdirSync(dir, { recursive: true }) } catch {}
|
|
622
689
|
const fallbackPath = join(dir, ".ostacky-handoff-compaction.json")
|
|
690
|
+
const contextSnippet = (() => {
|
|
691
|
+
try {
|
|
692
|
+
return JSON.stringify(event.messages.slice(0, 2)).slice(0, 800)
|
|
693
|
+
} catch { return "" }
|
|
694
|
+
})()
|
|
623
695
|
const payload = {
|
|
624
|
-
summary: `Compaction fallback for session ${
|
|
696
|
+
summary: `Compaction fallback for session ${event.sessionID ?? "unknown"} — plugin`,
|
|
625
697
|
nextSteps: [] as string[],
|
|
626
698
|
pendingTasks: [] as string[],
|
|
627
699
|
ts: Date.now(),
|
|
628
|
-
contextSnippet
|
|
700
|
+
contextSnippet,
|
|
629
701
|
}
|
|
630
702
|
const tmp = `${fallbackPath}.tmp.${process.pid}`
|
|
631
703
|
writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
|
|
632
704
|
renameSync(tmp, fallbackPath)
|
|
633
705
|
} catch {}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
}
|
|
706
|
+
})
|
|
637
707
|
|
|
638
|
-
|
|
708
|
+
return () => {
|
|
709
|
+
try { if (pingInterval) clearInterval(pingInterval) } catch {}
|
|
710
|
+
pingInterval = null
|
|
711
|
+
eventController.abort()
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
})
|