opencode-codeops 1.8.1 → 1.10.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 (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +18 -0
  3. package/_shared/quality-profile.md +9 -0
  4. package/_shared/reasoning-effort.md +135 -0
  5. package/_shared/workspace-hygiene.md +53 -0
  6. package/agent-templates/codebase-scout.md +6 -0
  7. package/agent-templates/concurrency-auditor.md +7 -0
  8. package/agent-templates/design-challenger.md +6 -0
  9. package/agent-templates/domain-specialist-executor.md +6 -0
  10. package/agent-templates/domain-specialist-reviewer.md +6 -0
  11. package/agent-templates/financial-integrity-auditor.md +7 -0
  12. package/agent-templates/perf-auditor.md +6 -0
  13. package/agent-templates/phase-reviewer.md +6 -0
  14. package/agent-templates/plan-task-executor-opus.md +6 -0
  15. package/agent-templates/plan-task-executor.md +6 -0
  16. package/agent-templates/preflight-auditor.md +6 -0
  17. package/agent-templates/security-auditor.md +6 -0
  18. package/agent-templates/semantics-reviewer.md +7 -0
  19. package/agent-templates/spec-test-author.md +6 -0
  20. package/agents/concurrency-auditor.md +7 -0
  21. package/agents/correctness-reviewer.md +6 -0
  22. package/agents/demanding-executor.md +6 -0
  23. package/agents/design-challenger.md +6 -0
  24. package/agents/executor.md +6 -0
  25. package/agents/explorer.md +6 -0
  26. package/agents/financial-integrity-auditor.md +7 -0
  27. package/agents/performance-auditor.md +6 -0
  28. package/agents/preflight-auditor.md +6 -0
  29. package/agents/security-auditor.md +6 -0
  30. package/agents/semantics-reviewer.md +7 -0
  31. package/agents/spec-test-author.md +6 -0
  32. package/bin/lib/reasoning-effort.d.mts +85 -0
  33. package/bin/lib/reasoning-effort.mjs +331 -0
  34. package/bin/lib/tmp-hygiene.d.mts +38 -0
  35. package/bin/lib/tmp-hygiene.mjs +180 -0
  36. package/package.json +1 -1
  37. package/plugin/index.ts +146 -3
  38. package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
  39. package/scripts/codeops_effort.py +216 -0
  40. package/scripts/fixtures/catalog-executor.golden.md +6 -0
  41. package/skills/analyze-project/SKILL.md +8 -0
  42. package/skills/clean-comments/SKILL.md +8 -0
  43. package/skills/exec-plan/SKILL.md +21 -0
  44. package/skills/exec-plan/execution-protocol.md +52 -7
  45. package/skills/git-commit/SKILL.md +11 -2
  46. package/skills/github-issues/SKILL.md +8 -0
  47. package/skills/grill-me/SKILL.md +18 -0
  48. package/skills/make-plan/SKILL.md +25 -0
  49. package/skills/make-plan/templates.md +2 -0
  50. package/skills/make-requirements/SKILL.md +18 -0
  51. package/skills/outcome-review/SKILL.md +8 -0
  52. package/skills/preflight/SKILL.md +18 -0
  53. package/skills/retro-requirements/SKILL.md +18 -0
  54. package/skills/roadmap/SKILL.md +8 -0
  55. package/skills/setup-codeops/SKILL.md +8 -0
  56. package/skills/setup-routing/SKILL.md +10 -0
  57. package/skills/setup-routing/routing.md +16 -1
  58. package/skills/techdocs/SKILL.md +8 -0
  59. package/skills/upgrade-plan/SKILL.md +18 -0
  60. package/standards/coding-standards-full.md +19 -0
  61. package/standards/coding-standards.md +6 -0
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Type declarations for the CodeOps adaptive reasoning-effort helper.
3
+ *
4
+ * The helper is plain JavaScript so `node --test` can exercise it directly;
5
+ * these declarations give the TypeScript plugin entry point typed access.
6
+ */
7
+
8
+ /** A level from the four-level suggestion vocabulary. */
9
+ export type EffortLevel = "low" | "medium" | "high" | "max"
10
+
11
+ /** A reasoning value accepted by `routing.roles.<agent>.reasoning`. */
12
+ export type RoutingReasoning =
13
+ | "none"
14
+ | "minimal"
15
+ | "low"
16
+ | "medium"
17
+ | "high"
18
+ | "xhigh"
19
+ | "max"
20
+
21
+ /** The four levels a plan or skill may suggest. */
22
+ export declare const EFFORT_LEVELS: readonly EffortLevel[]
23
+
24
+ /** Check whether a value is one of the four suggestion levels. */
25
+ export declare function isEffortLevel(value: unknown): value is EffortLevel
26
+
27
+ /** The reasoning values accepted by routing role entries. */
28
+ export declare const ROUTING_REASONING_VALUES: readonly RoutingReasoning[]
29
+
30
+ /** Check whether a value is a valid routing reasoning entry. */
31
+ export declare function isRoutingReasoning(value: unknown): value is RoutingReasoning
32
+
33
+ /** Find the first valid dispatch marker across message text parts. */
34
+ export declare function findEffortMarker(texts: unknown): EffortLevel | undefined
35
+
36
+ /** Candidate sources for {@link resolveEffort}. */
37
+ export interface ResolveEffortInput {
38
+ marker?: unknown
39
+ session?: unknown
40
+ routing?: unknown
41
+ }
42
+
43
+ /** Resolve the effective level from the explicit sources, most specific first. */
44
+ export declare function resolveEffort(
45
+ input?: ResolveEffortInput
46
+ ): EffortLevel | RoutingReasoning | undefined
47
+
48
+ /** Compute the session state-file path inside the session temp directory. */
49
+ export declare function sessionEffortPath(sessionID?: string, base?: string): string
50
+
51
+ /** Parse session state-file text into a validated level. */
52
+ export declare function parseStateFile(text: string): EffortLevel | undefined
53
+
54
+ /** Read the session's reasoning-effort state file. */
55
+ export declare function readSessionEffort(
56
+ sessionID?: string,
57
+ base?: string
58
+ ): EffortLevel | undefined
59
+
60
+ /** Read an agent's explicit reasoning entry from the project routing config. */
61
+ export declare function readRoutingReasoning(
62
+ config: unknown,
63
+ agent: unknown
64
+ ): RoutingReasoning | undefined
65
+
66
+ /** Extract the model's runtime variant record, when the host exposes one. */
67
+ export declare function extractModelVariants(
68
+ model: unknown
69
+ ): Record<string, unknown> | undefined
70
+
71
+ /** Check whether a model advertises reasoning support. */
72
+ export declare function modelSupportsReasoning(model: unknown): boolean
73
+
74
+ /** Merge the model's variant options for a level into the request options. */
75
+ export declare function applyEffort(
76
+ options: Record<string, unknown>,
77
+ level: unknown,
78
+ model: unknown
79
+ ): Record<string, unknown>
80
+
81
+ /** Recursively merge plain objects into a new object. */
82
+ export declare function deepMergePlain(
83
+ target: unknown,
84
+ source: unknown
85
+ ): Record<string, unknown>
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Adaptive reasoning-effort helper for CodeOps sessions.
4
+ *
5
+ * The plugin needs one place to answer a simple question per request: "which
6
+ * reasoning level should this request use, if any?" This module answers it
7
+ * without any framework dependency so `node --test` can exercise every edge:
8
+ *
9
+ * - marker scanning over message text parts;
10
+ * - source precedence (dispatch marker, session flag, routing default);
11
+ * - routing-config lookup with hostile-shape tolerance;
12
+ * - provider-option application through the model's own variant record; and
13
+ * - reading the per-session state file written by the skills.
14
+ *
15
+ * Every function is deliberately total: malformed input yields "no override",
16
+ * never a thrown error. The plugin hooks sit on the request path, where a
17
+ * crash would break the user's session, so safety beats strictness here.
18
+ *
19
+ * @module lib/reasoning-effort
20
+ */
21
+
22
+ import { lstatSync, readFileSync } from "node:fs"
23
+ import { join } from "node:path"
24
+
25
+ import { sessionTmpDir } from "./tmp-hygiene.mjs"
26
+
27
+ /**
28
+ * The four levels a plan or skill may suggest.
29
+ *
30
+ * These are the only values accepted in dispatch markers, session flags, and
31
+ * plan suggestions. Routing policy is project configuration and may name the
32
+ * wider provider enum instead.
33
+ */
34
+ export const EFFORT_LEVELS = Object.freeze(["low", "medium", "high", "max"])
35
+
36
+ /**
37
+ * The reasoning values accepted by `routing.roles.<agent>.reasoning` in the
38
+ * project configuration.
39
+ *
40
+ * The list mirrors the CodeOps config schema, which passes provider-native
41
+ * values through so a project can ask for `minimal` or `xhigh` even though the
42
+ * suggestion vocabulary only offers the four common levels.
43
+ */
44
+ export const ROUTING_REASONING_VALUES = Object.freeze([
45
+ "none",
46
+ "minimal",
47
+ "low",
48
+ "medium",
49
+ "high",
50
+ "xhigh",
51
+ "max",
52
+ ])
53
+
54
+ /**
55
+ * Largest state file accepted by {@link readSessionEffort}.
56
+ *
57
+ * The real file is a few dozen bytes. The cap keeps a hostile or corrupt file
58
+ * from being slurped into memory if the temp directory is ever tampered with.
59
+ */
60
+ const MAX_STATE_FILE_BYTES = 4096
61
+
62
+ /**
63
+ * One standalone marker line, anchored to the whole line.
64
+ *
65
+ * The marker is intentionally different from the human-readable plan line
66
+ * `> **Reasoning**: ...`, so quoting a plan can never act as a machine
67
+ * directive. Whitespace inside the brackets is tolerated; text before or after
68
+ * is not.
69
+ */
70
+ const MARKER_PATTERN = /^\[codeops-effort:\s*(low|medium|high|max)\]\s*$/
71
+
72
+ /**
73
+ * Check whether a value is one of the four suggestion levels.
74
+ *
75
+ * @param value - Value to inspect
76
+ * @returns True only for `"low"`, `"medium"`, `"high"`, or `"max"`
77
+ *
78
+ * @example
79
+ * isEffortLevel("high") // true
80
+ * isEffortLevel("xhigh") // false
81
+ */
82
+ export function isEffortLevel(value) {
83
+ return typeof value === "string" && EFFORT_LEVELS.includes(value)
84
+ }
85
+
86
+ /**
87
+ * Check whether a value is a valid routing reasoning entry.
88
+ *
89
+ * @param value - Value to inspect
90
+ * @returns True for any value in the routing enum
91
+ */
92
+ export function isRoutingReasoning(value) {
93
+ return typeof value === "string" && ROUTING_REASONING_VALUES.includes(value)
94
+ }
95
+
96
+ /**
97
+ * Find the first valid dispatch marker across a list of message text parts.
98
+ *
99
+ * Only string entries are inspected; non-string entries (tool parts, images,
100
+ * malformed structures) are skipped so a hostile part can never break the
101
+ * request path. Lines are split on `\n`, `\r\n`, and `\r`, then trimmed before
102
+ * the anchored pattern is applied, so surrounding whitespace is tolerated.
103
+ *
104
+ * @param texts - Candidate text parts (usually the text of a message's parts)
105
+ * @returns The first valid level, or `undefined` when none is present
106
+ *
107
+ * @example
108
+ * findEffortMarker(["run the task", "[codeops-effort: medium]"]) // "medium"
109
+ */
110
+ export function findEffortMarker(texts) {
111
+ if (!Array.isArray(texts)) return undefined
112
+ for (const text of texts) {
113
+ if (typeof text !== "string") continue
114
+ for (const line of text.split(/\r\n|\r|\n/)) {
115
+ const match = MARKER_PATTERN.exec(line.trim())
116
+ if (match) return match[1]
117
+ }
118
+ }
119
+ return undefined
120
+ }
121
+
122
+ /**
123
+ * Resolve the effective level from the three explicit sources.
124
+ *
125
+ * Precedence is "most specific wins": a dispatch marker beats a session
126
+ * flag, which beats a routing default. Each source is validated before it is
127
+ * accepted, so an invalid value is ignored rather than propagated. A
128
+ * non-object argument yields `undefined` instead of throwing.
129
+ *
130
+ * @param input - Candidate sources; all optional
131
+ * @returns The first valid value, or `undefined` when no source applies
132
+ *
133
+ * @example
134
+ * resolveEffort({ session: "high", routing: "low" }) // "high"
135
+ */
136
+ export function resolveEffort(input) {
137
+ if (!isPlainObject(input)) return undefined
138
+ const { marker, session, routing } = input
139
+ if (isEffortLevel(marker)) return marker
140
+ if (isEffortLevel(session)) return session
141
+ if (isRoutingReasoning(routing)) return routing
142
+ return undefined
143
+ }
144
+
145
+ /**
146
+ * Compute the session state-file path inside the session temp directory.
147
+ *
148
+ * @param sessionID - Session identifier
149
+ * @param base - Base temp directory (injectable for tests)
150
+ * @returns Absolute path to `reasoning-effort.json` for the session
151
+ */
152
+ export function sessionEffortPath(sessionID, base) {
153
+ return join(sessionTmpDir(sessionID, base), "reasoning-effort.json")
154
+ }
155
+
156
+ /**
157
+ * Parse session state-file text into a validated level.
158
+ *
159
+ * The expected payload is `{"schema":1,"reasoning":"<level>"}` with any
160
+ * unknown keys ignored. Anything else — malformed JSON, the wrong schema, a
161
+ * level outside the four-level allowlist — yields `undefined`.
162
+ *
163
+ * @param text - Raw file contents
164
+ * @returns The validated level, or `undefined`
165
+ */
166
+ export function parseStateFile(text) {
167
+ try {
168
+ const value = JSON.parse(text)
169
+ if (!isPlainObject(value)) return undefined
170
+ if (value.schema !== 1) return undefined
171
+ return isEffortLevel(value.reasoning) ? value.reasoning : undefined
172
+ } catch {
173
+ return undefined
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Read the session's reasoning-effort state file.
179
+ *
180
+ * The file is written by `scripts/codeops_effort.py` for `--auto-effort`
181
+ * runs. Only a small regular file is accepted: a missing file, a non-regular
182
+ * file (symlinks are never followed), an oversized file, and any read or parse
183
+ * failure all mean "no session effort" and return `undefined`.
184
+ *
185
+ * @param sessionID - Session identifier
186
+ * @param base - Base temp directory (injectable for tests)
187
+ * @returns The session level, or `undefined`
188
+ */
189
+ export function readSessionEffort(sessionID, base) {
190
+ try {
191
+ const path = sessionEffortPath(sessionID, base)
192
+ const info = lstatSync(path)
193
+ if (!info.isFile()) return undefined
194
+ if (info.size > MAX_STATE_FILE_BYTES) return undefined
195
+ return parseStateFile(readFileSync(path, "utf-8"))
196
+ } catch {
197
+ return undefined
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Read an agent's explicit reasoning entry from the project routing config.
203
+ *
204
+ * Only an own `routing.roles.<agent>.reasoning` property counts; unknown
205
+ * agents, missing sections, inherited properties, and malformed shapes all
206
+ * return `undefined` instead of throwing.
207
+ *
208
+ * @param config - Parsed `codeops/codeops.json` content (any shape)
209
+ * @param agent - Dispatching agent name
210
+ * @returns The configured routing value, or `undefined`
211
+ */
212
+ export function readRoutingReasoning(config, agent) {
213
+ if (!isPlainObject(config)) return undefined
214
+ const routing = config.routing
215
+ if (!isPlainObject(routing)) return undefined
216
+ const roles = routing.roles
217
+ if (!isPlainObject(roles)) return undefined
218
+ if (typeof agent !== "string" || agent.length === 0) return undefined
219
+ if (!Object.prototype.hasOwnProperty.call(roles, agent)) return undefined
220
+ const entry = roles[agent]
221
+ if (!isPlainObject(entry)) return undefined
222
+ return isRoutingReasoning(entry.reasoning) ? entry.reasoning : undefined
223
+ }
224
+
225
+ /**
226
+ * Extract the model's runtime variant record, when the host exposes one.
227
+ *
228
+ * The installed SDK type does not declare `variants`, but the running host
229
+ * attaches it to every model. The `in` check keeps this graceful when the
230
+ * property is absent, and a cast is never used.
231
+ *
232
+ * @param model - Model object from the hook input
233
+ * @returns The variants record when it is a plain object, else `undefined`
234
+ */
235
+ export function extractModelVariants(model) {
236
+ if (!isPlainObject(model)) return undefined
237
+ if (!("variants" in model)) return undefined
238
+ return isPlainObject(model.variants) ? model.variants : undefined
239
+ }
240
+
241
+ /**
242
+ * Check whether a model advertises reasoning support.
243
+ *
244
+ * @param model - Model object from the hook input
245
+ * @returns True only when `capabilities.reasoning` is exactly `true`
246
+ */
247
+ export function modelSupportsReasoning(model) {
248
+ if (!isPlainObject(model)) return false
249
+ const capabilities = model.capabilities
250
+ if (!isPlainObject(capabilities)) return false
251
+ return capabilities.reasoning === true
252
+ }
253
+
254
+ /**
255
+ * Merge the model's variant options for a level into the request options.
256
+ *
257
+ * The runtime model carries a `variants` record whose entries are the exact
258
+ * provider options for each level (for example `reasoningEffort`, or a nested
259
+ * `reasoning.effort`). This function is the only place that mapping is
260
+ * consumed, so the plugin never hardcodes a provider key. The function is
261
+ * pure: it returns a new object when a change applies and the original object
262
+ * reference otherwise.
263
+ *
264
+ * @param options - Current provider options
265
+ * @param level - Candidate level from {@link resolveEffort}
266
+ * @param model - Model object from the hook input
267
+ * @returns The original options, or a new merged object when a change applies
268
+ */
269
+ export function applyEffort(options, level, model) {
270
+ if (!isRoutingReasoning(level)) return options
271
+ if (!modelSupportsReasoning(model)) return options
272
+
273
+ const variants = extractModelVariants(model)
274
+ if (variants !== undefined) {
275
+ if (!Object.prototype.hasOwnProperty.call(variants, level)) return options
276
+ const variantOptions = variants[level]
277
+ if (!isPlainObject(variantOptions)) return options
278
+ return deepMergePlain(options, variantOptions)
279
+ }
280
+
281
+ return { ...options, reasoningEffort: level }
282
+ }
283
+
284
+ /**
285
+ * Recursively merge plain objects into a new object.
286
+ *
287
+ * Values that are not plain objects — arrays, class instances, primitives —
288
+ * are replaced, not merged. Neither input is mutated. The result is created
289
+ * with data properties, so a hostile `__proto__` key in a variant can never
290
+ * change the result's prototype chain.
291
+ *
292
+ * @param target - Base object
293
+ * @param source - Overrides to merge on top
294
+ * @returns A new merged plain object
295
+ */
296
+ export function deepMergePlain(target, source) {
297
+ const result = isPlainObject(target) ? { ...target } : {}
298
+ if (!isPlainObject(source)) return result
299
+
300
+ for (const key of Object.keys(source)) {
301
+ const incoming = source[key]
302
+ const current = Object.prototype.hasOwnProperty.call(result, key)
303
+ ? result[key]
304
+ : undefined
305
+ const merged =
306
+ isPlainObject(incoming) && isPlainObject(current)
307
+ ? deepMergePlain(current, incoming)
308
+ : incoming
309
+ Object.defineProperty(result, key, {
310
+ value: merged,
311
+ writable: true,
312
+ enumerable: true,
313
+ configurable: true,
314
+ })
315
+ }
316
+
317
+ return result
318
+ }
319
+
320
+ /**
321
+ * Check whether a value is a plain object (not an array, class instance, or
322
+ * `null`).
323
+ *
324
+ * @param value - Value to inspect
325
+ * @returns True for `{}`-shaped objects and objects with a null prototype
326
+ */
327
+ function isPlainObject(value) {
328
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false
329
+ const prototype = Object.getPrototypeOf(value)
330
+ return prototype === Object.prototype || prototype === null
331
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Type declarations for the CodeOps temporary-directory lifecycle helper.
3
+ *
4
+ * The helper is plain JavaScript so `node --test` can exercise it directly;
5
+ * these declarations give the TypeScript plugin entry point typed access.
6
+ */
7
+
8
+ /** Age after which a session directory is treated as abandoned. */
9
+ export declare const DEFAULT_MAX_AGE_MS: number
10
+
11
+ /** Directory name used when no usable session identifier exists. */
12
+ export declare const SHARED_SESSION_NAME: string
13
+
14
+ /** Resolve the CodeOps-owned temp root under a base temp directory. */
15
+ export declare function codeopsTmpRoot(base?: string): string
16
+
17
+ /** Reduce a session identifier to a safe single path segment. */
18
+ export declare function sanitizeSessionId(sessionID?: string): string
19
+
20
+ /** Compute the temp directory for one session. */
21
+ export declare function sessionTmpDir(sessionID?: string, base?: string): string
22
+
23
+ /** Create the session temp directory when it does not exist yet. */
24
+ export declare function ensureSessionTmpDir(sessionID?: string, base?: string): string
25
+
26
+ /** Inputs for a stale-directory sweep. */
27
+ export interface CleanStaleTmpDirsOptions {
28
+ sessionID?: string
29
+ base?: string
30
+ maxAgeMs?: number
31
+ now?: number
32
+ }
33
+
34
+ /** Remove session directories abandoned by interrupted or crashed runs. */
35
+ export declare function cleanStaleTmpDirs(options?: CleanStaleTmpDirsOptions): string[]
36
+
37
+ /** Remove exactly one session's temp directory. */
38
+ export declare function removeSessionTmpDir(sessionID?: string, base?: string): boolean
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Temporary-directory lifecycle for CodeOps sessions.
4
+ *
5
+ * The workspace-hygiene protocol asks every skill and agent to put scratch
6
+ * files under `$CODEOPS_TMPDIR` and delete them when the run completes. This
7
+ * module provides the safety net around that protocol:
8
+ *
9
+ * - it computes the per-session directory the plugin exports as
10
+ * `$CODEOPS_TMPDIR`, under a CodeOps-owned root inside the OS temp directory;
11
+ * - it sweeps directories abandoned by interrupted runs once they are older
12
+ * than a safety cap; and
13
+ * - it removes exactly one session's directory when that session is deleted.
14
+ *
15
+ * Safety is the whole point: every path derives from a sanitized session
16
+ * identifier, the sweep walks only the CodeOps-owned root, symlinked entries
17
+ * are treated as foreign, and cleanup is best effort so a failure never breaks
18
+ * a session.
19
+ *
20
+ * @module lib/tmp-hygiene
21
+ */
22
+
23
+ import { lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs"
24
+ import { tmpdir } from "node:os"
25
+ import { join, resolve, sep } from "node:path"
26
+
27
+ /**
28
+ * Age after which a session directory is treated as abandoned.
29
+ *
30
+ * A crashed or interrupted run leaves its directory behind; once it is this
31
+ * old, no live session can still need it, so the next sweep may remove it.
32
+ */
33
+ export const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
34
+
35
+ /** Directory name used when no usable session identifier exists. */
36
+ export const SHARED_SESSION_NAME = "shared"
37
+
38
+ /**
39
+ * Resolve the CodeOps-owned temp root under a base temp directory.
40
+ *
41
+ * The plugin owns this subtree and nothing else under the system temp
42
+ * directory; keeping all CodeOps scratch under one root is what makes the
43
+ * automatic sweep safe.
44
+ *
45
+ * @param base - Base temp directory (injectable for tests)
46
+ * @returns Path to the CodeOps temp root
47
+ */
48
+ export function codeopsTmpRoot(base = tmpdir()) {
49
+ return join(base, "opencode", "codeops")
50
+ }
51
+
52
+ /**
53
+ * Reduce a session identifier to a safe single path segment.
54
+ *
55
+ * Session identifiers come from the host and must never influence the path
56
+ * shape: every character outside `A-Z`, `a-z`, `0-9`, `_`, and `-` is removed,
57
+ * so a value like `../../etc` cannot escape the temp root. An empty result
58
+ * falls back to a shared directory name.
59
+ *
60
+ * @param sessionID - Raw session identifier (may be missing)
61
+ * @returns A safe directory name
62
+ */
63
+ export function sanitizeSessionId(sessionID) {
64
+ const cleaned = String(sessionID ?? "").replace(/[^A-Za-z0-9_-]/g, "")
65
+ return cleaned.length > 0 ? cleaned : SHARED_SESSION_NAME
66
+ }
67
+
68
+ /**
69
+ * Compute the temp directory for one session.
70
+ *
71
+ * @param sessionID - Raw session identifier (may be missing)
72
+ * @param base - Base temp directory (injectable for tests)
73
+ * @returns Path to the session's temp directory
74
+ */
75
+ export function sessionTmpDir(sessionID, base = tmpdir()) {
76
+ return join(codeopsTmpRoot(base), sanitizeSessionId(sessionID))
77
+ }
78
+
79
+ /**
80
+ * Create the session temp directory when it does not exist yet.
81
+ *
82
+ * Called while exporting the shell environment, so the directory is ready
83
+ * before the first command needs it.
84
+ *
85
+ * @param sessionID - Raw session identifier (may be missing)
86
+ * @param base - Base temp directory (injectable for tests)
87
+ * @returns Path to the session's temp directory
88
+ * @throws {Error} When the directory cannot be created
89
+ */
90
+ export function ensureSessionTmpDir(sessionID, base = tmpdir()) {
91
+ const dir = sessionTmpDir(sessionID, base)
92
+ mkdirSync(dir, { recursive: true })
93
+ return dir
94
+ }
95
+
96
+ /**
97
+ * Remove session directories abandoned by interrupted or crashed runs.
98
+ *
99
+ * Only direct children of the CodeOps temp root are considered. Fresh
100
+ * directories, the current session's directory, plain files, and symlinks are
101
+ * left untouched; a symlink is never followed, so a link planted inside the
102
+ * root can never redirect a delete outside it. Failures are swallowed because
103
+ * a sweep is maintenance, not a task requirement.
104
+ *
105
+ * @param options - Sweep inputs
106
+ * @param options.sessionID - Current session identifier to preserve
107
+ * @param options.base - Base temp directory (injectable for tests)
108
+ * @param options.maxAgeMs - Abandonment age in milliseconds
109
+ * @param options.now - Current time in milliseconds (injectable for tests)
110
+ * @returns Paths of the directories that were removed
111
+ */
112
+ export function cleanStaleTmpDirs({
113
+ sessionID,
114
+ base = tmpdir(),
115
+ maxAgeMs = DEFAULT_MAX_AGE_MS,
116
+ now = Date.now(),
117
+ } = {}) {
118
+ const root = codeopsTmpRoot(base)
119
+ const keep = resolve(sessionTmpDir(sessionID, base))
120
+ const removed = []
121
+
122
+ let entries
123
+ try {
124
+ entries = readdirSync(root, { withFileTypes: true })
125
+ } catch {
126
+ return removed
127
+ }
128
+
129
+ for (const entry of entries) {
130
+ // Directory entries are lstat-based, so a symlink to a directory reports
131
+ // as a symlink and is skipped here.
132
+ if (!entry.isDirectory()) continue
133
+
134
+ const full = join(root, entry.name)
135
+ if (resolve(full) === keep) continue
136
+
137
+ try {
138
+ const info = lstatSync(full)
139
+ if (!info.isDirectory()) continue
140
+ if (now - info.mtimeMs < maxAgeMs) continue
141
+ rmSync(full, { recursive: true, force: true })
142
+ removed.push(full)
143
+ } catch {
144
+ // Best effort: a directory that vanished mid-sweep is already gone.
145
+ }
146
+ }
147
+
148
+ return removed
149
+ }
150
+
151
+ /**
152
+ * Remove exactly one session's temp directory.
153
+ *
154
+ * Used when a session is deleted: its scratch can no longer be needed. The
155
+ * removal refuses anything that is not a regular directory strictly inside
156
+ * the CodeOps temp root, so a symlink or an unexpected path is left alone.
157
+ *
158
+ * @param sessionID - Session identifier whose directory should be removed
159
+ * @param base - Base temp directory (injectable for tests)
160
+ * @returns True when the directory was removed
161
+ */
162
+ export function removeSessionTmpDir(sessionID, base = tmpdir()) {
163
+ const root = resolve(codeopsTmpRoot(base))
164
+ const dir = sessionTmpDir(sessionID, base)
165
+
166
+ if (!resolve(dir).startsWith(root + sep)) return false
167
+
168
+ try {
169
+ if (!lstatSync(dir).isDirectory()) return false
170
+ } catch {
171
+ return false
172
+ }
173
+
174
+ try {
175
+ rmSync(dir, { recursive: true, force: true })
176
+ return true
177
+ } catch {
178
+ return false
179
+ }
180
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codeops",
3
- "version": "1.8.1",
3
+ "version": "1.10.0",
4
4
  "description": "Specification-first engineering for complex systems — CodeOps plugin for OpenCode",
5
5
  "type": "module",
6
6
  "engines": {