@xemahq/opencode-xema-plugin 0.1.4 → 0.1.5

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.
@@ -0,0 +1,411 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── `session.idle` auto-commit hook ──
3
+ //
4
+ // Fires on every opencode `session.idle` event (delivered via the generic
5
+ // `event` hook). For sessions opted into per-turn auto-commit
6
+ // (`context.git.autoCommit === 'enabled'` — the
7
+ // `shared_dev_publish_to_prod` BranchMode default), the hook:
8
+ //
9
+ // 1. Reads context.json for sessionId + actor + repoSubPath.
10
+ // 2. Reads the actor JWT from `$WORKER_ACTOR_JWT_PATH` (default
11
+ // `/run/xema/actor.jwt`). Missing JWT → log warn + skip (best-effort).
12
+ // 3. Runs `git status --porcelain` against `<workspace>/<repoSubPath>`.
13
+ // Clean tree → skip (no-op).
14
+ // 4. POSTs `$WORKSPACE_GIT_API_URL/sessions/<sid>/autocommit` with the
15
+ // actor JWT as `Authorization: Bearer …`. Body:
16
+ // { turn, actor: { kind, subjectRef, displayName },
17
+ // committed, sha, pushFailed }
18
+ // Wire contract: this endpoint records state; the local git ops
19
+ // (add/commit/push) happen ON the worker — see design note history
20
+ // that this file replaced. workspace-proxy ships
21
+ // `/control/git/autocommit` which workspace-git-api will eventually
22
+ // delegate to; the hook signature is stable across that switch.
23
+ // 5. Translates the response into local actions:
24
+ // - `committed=true, conflictDetected=false` → log success.
25
+ // - `committed=true, pushFailed=true` → one retry w/ 5s backoff;
26
+ // on second failure, log + continue (NEVER block the next turn).
27
+ // - `conflictDetected=true` → emit `xema.conflict.detected` event
28
+ // (best-effort; sets a process-local flag readable by other
29
+ // hooks) so the next user message can surface a "Conflict —
30
+ // Publish needed" affordance.
31
+ // - Any HTTP error → log + continue.
32
+ //
33
+ // HARD INVARIANT: this hook MUST NEVER throw out of the opencode
34
+ // dispatcher. Auto-commit is best-effort orientation; failing here would
35
+ // stall every user's next turn. The entire body runs inside a top-level
36
+ // `try { … } catch { warn(...); }` and returns `undefined` on any error.
37
+ //
38
+ // Wire contract (kept in sync with biomes/software-dev/api/workspace-git-api):
39
+ //
40
+ // POST {WORKSPACE_GIT_API_URL}/sessions/{sessionId}/autocommit
41
+ // Authorization: Bearer <actor-jwt>
42
+ // Content-Type: application/json
43
+ //
44
+ // { "turn": 7,
45
+ // "actor": { "kind": "org_user", "subjectRef": "user_…",
46
+ // "displayName": "Alice" },
47
+ // "committed": true,
48
+ // "sha": "ab12cd34…",
49
+ // "pushFailed": false }
50
+ //
51
+ // → 200 { committed, sha, pushFailed, conflictDetected }
52
+ //
53
+ // See [packages/runtime/opencode-xema-plugin/CHANGELOG] for the broader
54
+ // handoff context. The plaintext design note
55
+ // previously at `hooks/_TODO_session-idle-autocommit.md` was deleted
56
+ // when this file landed.
57
+ // ═══════════════════════════════════════════════════════════════════════════
58
+
59
+ import { execFile } from 'node:child_process';
60
+ import { existsSync, readFileSync } from 'node:fs';
61
+ import { join } from 'node:path';
62
+ import { promisify } from 'node:util';
63
+
64
+ import { readInvocationContextForHook, type InvocationContext } from '../context.js';
65
+ import { warn, info, debug } from '../logger.js';
66
+ import type { OpencodeEvent, OpencodeSessionIdleEvent } from '../opencode-types.js';
67
+
68
+ const HOOK_NAME = 'session.idle';
69
+ const DEFAULT_JWT_PATH = '/run/xema/actor.jwt';
70
+ const PUSH_RETRY_DELAY_MS = 5_000;
71
+
72
+ const execFileAsync = promisify(execFile);
73
+
74
+ interface AutocommitRequestBody {
75
+ turn: number;
76
+ actor: {
77
+ kind: 'org_user';
78
+ subjectRef: string;
79
+ displayName: string;
80
+ };
81
+ committed: boolean;
82
+ sha: string | null;
83
+ pushFailed: boolean;
84
+ }
85
+
86
+ interface AutocommitResponseBody {
87
+ committed: boolean;
88
+ sha: string | null;
89
+ pushFailed: boolean;
90
+ conflictDetected: boolean;
91
+ }
92
+
93
+ /**
94
+ * Process-local flag set when workspace-git-api last reported
95
+ * `conflictDetected=true`. Other hooks (e.g. a future chat-message
96
+ * pre-send hook) can read it via {@link readConflictFlag} to surface a
97
+ * "Conflict — Publish needed" affordance to the user. Per-session
98
+ * because a single worker hosts multiple sessions in pool mode.
99
+ */
100
+ const conflictFlagBySessionId = new Map<string, { at: number; turn: number }>();
101
+
102
+ export function readConflictFlag(
103
+ sessionId: string,
104
+ ): { at: number; turn: number } | undefined {
105
+ return conflictFlagBySessionId.get(sessionId);
106
+ }
107
+
108
+ export function clearConflictFlag(sessionId: string): void {
109
+ conflictFlagBySessionId.delete(sessionId);
110
+ }
111
+
112
+ /**
113
+ * Per-session turn counter. `session.idle` fires once per LLM turn, so
114
+ * we increment on receipt before performing the autocommit work. This
115
+ * is the only place inside the worker that sees the boundary
116
+ * deterministically.
117
+ */
118
+ const turnCounterBySessionId = new Map<string, number>();
119
+
120
+ /**
121
+ * Test-only: reset all in-memory state. Not exported from the package
122
+ * barrel.
123
+ */
124
+ export function __resetSessionIdleAutocommitStateForTests(): void {
125
+ conflictFlagBySessionId.clear();
126
+ turnCounterBySessionId.clear();
127
+ }
128
+
129
+ /**
130
+ * Factory: returns the `event` hook bound to a workspace directory. The
131
+ * hook itself dispatches on `event.type === 'session.idle'` and ignores
132
+ * everything else.
133
+ */
134
+ export function buildSessionIdleAutocommit(
135
+ workspaceDir: string,
136
+ ): (input: { event: OpencodeEvent }) => Promise<void> {
137
+ return async (input) => {
138
+ const evt = input.event;
139
+ if (evt.type !== 'session.idle') {
140
+ return;
141
+ }
142
+ const idleEvent = evt as OpencodeSessionIdleEvent;
143
+ try {
144
+ await handleSessionIdle(workspaceDir, idleEvent);
145
+ } catch (err) {
146
+ // HARD invariant: never propagate. Auto-commit is best-effort.
147
+ const message = err instanceof Error ? err.message : String(err);
148
+ warn(
149
+ `hook=${HOOK_NAME}: unexpected error swallowed (auto-commit is best-effort): ${message}`,
150
+ );
151
+ }
152
+ };
153
+ }
154
+
155
+ async function handleSessionIdle(
156
+ workspaceDir: string,
157
+ evt: OpencodeSessionIdleEvent,
158
+ ): Promise<void> {
159
+ const sessionId = evt.properties.sessionID;
160
+ if (!sessionId) {
161
+ debug(`hook=${HOOK_NAME}: event missing sessionID; skipping`);
162
+ return;
163
+ }
164
+
165
+ const ctx = readInvocationContextForHook(workspaceDir, HOOK_NAME);
166
+ if (!ctx) {
167
+ // Non-Xema session or pre-mount: nothing to commit.
168
+ return;
169
+ }
170
+
171
+ if (ctx.git?.autoCommit !== 'enabled') {
172
+ debug(
173
+ `hook=${HOOK_NAME}: session=${sessionId} autoCommit not enabled (got ` +
174
+ `${ctx.git?.autoCommit ?? 'absent'}); skipping`,
175
+ );
176
+ return;
177
+ }
178
+
179
+ if (!ctx.actor) {
180
+ warn(
181
+ `hook=${HOOK_NAME}: session=${sessionId} has git.autoCommit=enabled but ` +
182
+ `no actor in context.json; skipping. The orchestrator must stamp ` +
183
+ `context.actor for sessions opted into per-turn auto-commit.`,
184
+ );
185
+ return;
186
+ }
187
+
188
+ // ── Why an env var and not the KernelState/etcd service registry ──
189
+ //
190
+ // This plugin runs INSIDE the agent-workspace container, which is
191
+ // deliberately OUTSIDE the etcd service mesh: it holds no registry client,
192
+ // no etcd credentials, and no service identity of its own — its only
193
+ // identity is the actor JWT the orchestrator mounts. So `WORKSPACE_GIT_API_URL`
194
+ // is an ORCHESTRATOR-INJECTED peer URL, not the retired `<SVC>_API_URL`
195
+ // discovery pattern that first-party in-mesh services must never use. The
196
+ // orchestrator resolves the peer FROM the registry and injects the resolved
197
+ // value into the container envelope; this is the only category of code
198
+ // allowed to read a peer URL from the environment.
199
+ //
200
+ // Absent ⇒ SKIP with a warn, never a throw: see this module's HARD
201
+ // INVARIANT — a `session.idle` hook that throws stalls every user's next
202
+ // turn. The warn names the operator fix, so the degradation is observable,
203
+ // not silent.
204
+ const apiUrl = process.env.WORKSPACE_GIT_API_URL;
205
+ if (!apiUrl || apiUrl.length === 0) {
206
+ warn(
207
+ `hook=${HOOK_NAME}: session=${sessionId} WORKSPACE_GIT_API_URL env not set; ` +
208
+ `skipping auto-commit. Wire the env into the worker container envelope.`,
209
+ );
210
+ return;
211
+ }
212
+
213
+ const jwtPath = process.env.WORKER_ACTOR_JWT_PATH ?? DEFAULT_JWT_PATH;
214
+ const actorJwt = readActorJwt(jwtPath);
215
+ if (actorJwt === null) {
216
+ warn(
217
+ `hook=${HOOK_NAME}: session=${sessionId} actor JWT not readable at ${jwtPath}; ` +
218
+ `skipping auto-commit. Verify the orchestrator mounts the actor JWT ` +
219
+ `before session start.`,
220
+ );
221
+ return;
222
+ }
223
+
224
+ // TODO(multi-repo): when the workspace can host multiple
225
+ // checked-out repos (persistent-repos), `git.repoSubPath`
226
+ // becomes an array and the hook fans out one autocommit per repo. The
227
+ // workspace-git-api wire shape already keys on `(sessionId,
228
+ // repoBindingId)` server-side; the per-call body just needs the
229
+ // repoBindingId added. For now we commit at the single primary
230
+ // repo (or workspace root if none is configured).
231
+ const repoSubPath = ctx.git.repoSubPath ?? null;
232
+ const repoDir = repoSubPath ? join(workspaceDir, repoSubPath) : workspaceDir;
233
+
234
+ const dirty = await isGitTreeDirty(repoDir);
235
+ if (dirty === undefined) {
236
+ // git status itself failed — log and skip; nothing we can usefully do.
237
+ return;
238
+ }
239
+ if (!dirty) {
240
+ debug(
241
+ `hook=${HOOK_NAME}: session=${sessionId} clean tree at ${repoDir}; ` +
242
+ `skipping auto-commit`,
243
+ );
244
+ return;
245
+ }
246
+
247
+ const turn = nextTurn(sessionId);
248
+ const body: AutocommitRequestBody = {
249
+ turn,
250
+ actor: {
251
+ kind: ctx.actor.kind,
252
+ subjectRef: ctx.actor.subjectRef,
253
+ displayName: ctx.actor.displayName,
254
+ },
255
+ // Wire contract: the worker reports the OUTCOME. For the v1
256
+ // hook we forward "needs commit; service will perform / record it
257
+ // via the workspace-proxy bridge once available". Until then
258
+ // workspace-git-api treats `committed=true` as "you produced a
259
+ // commit locally, please record it"; the actual git push happens
260
+ // through the bridge.
261
+ committed: true,
262
+ sha: null,
263
+ pushFailed: false,
264
+ };
265
+
266
+ const endpoint = buildAutocommitUrl(apiUrl, sessionId);
267
+ const response = await postAutocommit(endpoint, actorJwt, body, ctx);
268
+ if (response === null) {
269
+ return; // already logged
270
+ }
271
+
272
+ if (response.conflictDetected) {
273
+ conflictFlagBySessionId.set(sessionId, { at: Date.now(), turn });
274
+ info(
275
+ `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} conflict detected ` +
276
+ `(remote diverged); next user message will surface the Publish affordance`,
277
+ );
278
+ return;
279
+ }
280
+
281
+ if (response.pushFailed) {
282
+ info(
283
+ `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} push failed; ` +
284
+ `retrying once after ${PUSH_RETRY_DELAY_MS}ms`,
285
+ );
286
+ await sleep(PUSH_RETRY_DELAY_MS);
287
+ const retry = await postAutocommit(endpoint, actorJwt, body, ctx);
288
+ if (retry === null || retry.pushFailed) {
289
+ warn(
290
+ `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} push failed twice; ` +
291
+ `giving up (auto-commit is best-effort). User-driven Publish flow ` +
292
+ `remains available.`,
293
+ );
294
+ return;
295
+ }
296
+ if (retry.conflictDetected) {
297
+ conflictFlagBySessionId.set(sessionId, { at: Date.now(), turn });
298
+ info(
299
+ `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} retry surfaced conflict`,
300
+ );
301
+ return;
302
+ }
303
+ info(
304
+ `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} retry committed=${retry.committed}` +
305
+ ` sha=${retry.sha ?? '-'}`,
306
+ );
307
+ return;
308
+ }
309
+
310
+ info(
311
+ `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} committed=${response.committed}` +
312
+ ` sha=${response.sha ?? '-'}`,
313
+ );
314
+ }
315
+
316
+ function nextTurn(sessionId: string): number {
317
+ const prev = turnCounterBySessionId.get(sessionId) ?? 0;
318
+ const next = prev + 1;
319
+ turnCounterBySessionId.set(sessionId, next);
320
+ return next;
321
+ }
322
+
323
+ function readActorJwt(path: string): string | null {
324
+ try {
325
+ if (!existsSync(path)) {
326
+ return null;
327
+ }
328
+ const raw = readFileSync(path, 'utf-8').trim();
329
+ return raw.length > 0 ? raw : null;
330
+ } catch (err) {
331
+ const message = err instanceof Error ? err.message : String(err);
332
+ warn(`hook=${HOOK_NAME}: failed to read actor JWT at ${path}: ${message}`);
333
+ return null;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Returns true iff `git status --porcelain` produces non-empty output.
339
+ * `undefined` signals "could not determine" (git missing, not-a-repo,
340
+ * permission error, etc.) — the caller MUST treat this as "skip" rather
341
+ * than guess.
342
+ */
343
+ async function isGitTreeDirty(repoDir: string): Promise<boolean | undefined> {
344
+ try {
345
+ const { stdout } = await execFileAsync(
346
+ 'git',
347
+ ['-C', repoDir, 'status', '--porcelain'],
348
+ { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 },
349
+ );
350
+ return stdout.trim().length > 0;
351
+ } catch (err) {
352
+ const message = err instanceof Error ? err.message : String(err);
353
+ warn(
354
+ `hook=${HOOK_NAME}: git status failed in ${repoDir}: ${message}. ` +
355
+ `Skipping auto-commit for this turn.`,
356
+ );
357
+ return undefined;
358
+ }
359
+ }
360
+
361
+ function buildAutocommitUrl(apiBase: string, sessionId: string): string {
362
+ const base = apiBase.endsWith('/') ? apiBase.slice(0, -1) : apiBase;
363
+ return `${base}/sessions/${encodeURIComponent(sessionId)}/autocommit`;
364
+ }
365
+
366
+ async function postAutocommit(
367
+ url: string,
368
+ actorJwt: string,
369
+ body: AutocommitRequestBody,
370
+ ctx: InvocationContext,
371
+ ): Promise<AutocommitResponseBody | null> {
372
+ try {
373
+ const res = await fetch(url, {
374
+ method: 'POST',
375
+ headers: {
376
+ 'Content-Type': 'application/json',
377
+ Authorization: `Bearer ${actorJwt}`,
378
+ },
379
+ body: JSON.stringify(body),
380
+ });
381
+ if (!res.ok) {
382
+ const text = await safeReadBody(res);
383
+ warn(
384
+ `hook=${HOOK_NAME}: POST ${url} responded ${res.status} ${res.statusText} ` +
385
+ `(role=${ctx.invocation.role}, runId=${ctx.invocation.runId ?? '-'}): ${text}`,
386
+ );
387
+ return null;
388
+ }
389
+ const parsed = (await res.json()) as AutocommitResponseBody;
390
+ return parsed;
391
+ } catch (err) {
392
+ const message = err instanceof Error ? err.message : String(err);
393
+ warn(`hook=${HOOK_NAME}: POST ${url} failed: ${message}`);
394
+ return null;
395
+ }
396
+ }
397
+
398
+ async function safeReadBody(res: Response): Promise<string> {
399
+ try {
400
+ const text = await res.text();
401
+ return text.length > 512 ? `${text.slice(0, 512)}…` : text;
402
+ } catch {
403
+ return '<unreadable body>';
404
+ }
405
+ }
406
+
407
+ function sleep(ms: number): Promise<void> {
408
+ return new Promise((resolve) => {
409
+ setTimeout(resolve, ms);
410
+ });
411
+ }
@@ -0,0 +1,99 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { readInvocationContextForHook } from '../context.js';
5
+ import { XemaSystemOverlayMissingError } from '../errors.js';
6
+ import { debug } from '../logger.js';
7
+ import type {
8
+ ExperimentalChatSystemTransformInput,
9
+ ExperimentalChatSystemTransformOutput,
10
+ } from '../opencode-types.js';
11
+ import { renderWorkspaceListing } from './workspace-listing.js';
12
+
13
+ /**
14
+ * `experimental.chat.system.transform` hook — concatenates the rendered
15
+ * Xema platform overlay onto OpenCode's system prompt entry on every
16
+ * chat call.
17
+ *
18
+ * Behavior:
19
+ * - **Non-Xema session** (no `context.json` in `directory`) — pass
20
+ * through unchanged. The plugin must not affect non-Xema OpenCode
21
+ * usage.
22
+ * - **Xema session, overlay present** — append the overlay bytes to
23
+ * the END of `output.system[0]` (the existing single entry that
24
+ * OpenCode seeded with the agent prompt + any user system). The
25
+ * overlay is appended as the trailing block so its directives
26
+ * remain authoritative on conflict — LLMs treat later system text
27
+ * as more recent guidance.
28
+ * - **Xema session, overlay missing** — throw
29
+ * `XemaSystemOverlayMissingError`. The platform contract is
30
+ * undefined; we refuse to start the chat rather than letting an
31
+ * agent operate without the AWP base layer + deliverable contract
32
+ * + authority guidance the rest of the platform assumes is in
33
+ * scope. The agent activity surfaces this as a typed
34
+ * `OVERLAY_MISSING` error.
35
+ *
36
+ * Why concatenate instead of `output.system.push(bytes)`:
37
+ *
38
+ * OpenCode renders each entry of `output.system` as a SEPARATE wire
39
+ * message with `role: 'system'`. Its post-hook rejoin block in
40
+ * `session/llm.ts` only collapses entries when `system.length > 2`, so
41
+ * a single push from us produces `system.length === 2`, which slips
42
+ * past the rejoin and lands on the wire as two `role: 'system'`
43
+ * messages. OpenAI and Anthropic accept that shape; some
44
+ * OpenAI-compatible inference servers (notably stricter Qwen vLLM
45
+ * deployments) reject it with `400: System message must be at the
46
+ * beginning.` Concatenating into the existing entry keeps
47
+ * `system.length === 1` and produces exactly one leading system
48
+ * message regardless of provider strictness — fixing the symptom at
49
+ * the only point this plugin owns. (The OpenCode rejoin condition is
50
+ * a separate upstream bug; this defense is independent of it.)
51
+ */
52
+ export function buildSystemPromptOverlay(directory: string) {
53
+ return async (
54
+ _input: ExperimentalChatSystemTransformInput,
55
+ output: ExperimentalChatSystemTransformOutput,
56
+ ): Promise<void> => {
57
+ const ctx = readInvocationContextForHook(
58
+ directory,
59
+ 'experimental.chat.system.transform',
60
+ );
61
+ if (!ctx) {
62
+ // Not a Xema session — leave OpenCode's base prompt alone.
63
+ return;
64
+ }
65
+ const overlayPath = join(directory, '.xema', 'system-overlay.md');
66
+ if (!existsSync(overlayPath)) {
67
+ throw new XemaSystemOverlayMissingError(
68
+ `Xema session ${ctx.invocation.runId ?? 'unknown'} (role=${ctx.invocation.role}, ` +
69
+ `sessionId=${ctx.invocation.sessionId ?? '-'}) is missing /workspace/.xema/system-overlay.md. ` +
70
+ `The platform contract is undefined; refusing to start the chat. ` +
71
+ `Verify the agent-run-context render flow emitted the system-overlay slot.`,
72
+ );
73
+ }
74
+ const bytes = readFileSync(overlayPath, 'utf8');
75
+ // OpenCode seeds `output.system` with exactly one entry before
76
+ // calling this hook (see `session/llm.ts` — agent prompt + custom
77
+ // + user-system are joined into `system[0]`). If that contract
78
+ // ever changes (e.g. another plugin pushes ahead of us), fail
79
+ // loudly rather than silently producing multi-system payloads.
80
+ if (output.system.length !== 1) {
81
+ throw new Error(
82
+ `experimental.chat.system.transform: expected output.system to contain exactly 1 entry on hook entry, got ${output.system.length}. ` +
83
+ `Another plugin or an OpenCode change broke the seed-then-extend contract this hook relies on.`,
84
+ );
85
+ }
86
+ // Render the live depth-1 workspace tree AFTER the static overlay
87
+ // so the trailing block is the only volatile region of the system
88
+ // prompt. Everything above it (agent prompt, AWP base, deliverable
89
+ // contract, authority section) stays prefix-cacheable across
90
+ // turns; only this listing recomputes when files change. Do NOT
91
+ // move it earlier — that would invalidate the whole system-prompt
92
+ // cache on every turn that touches the workspace.
93
+ const listing = renderWorkspaceListing(directory);
94
+ output.system[0] = `${output.system[0]}\n\n${bytes}\n\n${listing}`;
95
+ debug(
96
+ `experimental.chat.system.transform: concatenated ${bytes.length} overlay bytes + ${listing.length} listing bytes into system[0] (role=${ctx.invocation.role})`,
97
+ );
98
+ };
99
+ }
@@ -0,0 +1,175 @@
1
+ import { readdirSync, statSync, type Dirent } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Renders the actual depth-1 contents of `/workspace` as a Markdown
6
+ * tree, grouped by AWP top-level slot. Appended to the system prompt
7
+ * by the chat-system-transform hook so the LLM sees the real layout —
8
+ * actual filenames, repo slugs, prior-turn deliverables — without
9
+ * having to spend a tool call on `glob` or `list`.
10
+ *
11
+ * Design constraints:
12
+ *
13
+ * - **Read-only, side-effect free.** Pure fs reads. No caching state,
14
+ * no writes, no network.
15
+ * - **Hides platform-private slots.** `.xema/` is platform-private —
16
+ * the LLM has no business reading or grep'ing it (its contents are
17
+ * already in the system prompt overlay). `.opencode/` is OpenCode
18
+ * runtime state (agent + skill bundles, plugin tree, snapshot
19
+ * metadata) — also hidden because operating on it is never the
20
+ * user's intent.
21
+ * - **Bounded output.** Each directory's listing is capped at
22
+ * {@link MAX_ENTRIES_PER_DIR}; overflow is summarised as
23
+ * `(N more entries — use \`glob\`/\`list\` to enumerate)` so the
24
+ * system prompt never balloons under sessions that accumulate
25
+ * hundreds of files in `deliverables/`.
26
+ * - **Stable ordering.** Entries are sorted alphabetically with
27
+ * directories first, so identical filesystem state produces
28
+ * identical bytes and the trailing prompt-cache window stays warm
29
+ * turn-to-turn when nothing changed.
30
+ * - **Cache positioning.** This block goes at the very END of the
31
+ * system prompt because it is the most volatile part. Anthropic +
32
+ * OpenAI prefix-cache the system prompt; everything ABOVE this
33
+ * block stays cached, only this trailing block recomputes.
34
+ */
35
+ export const MAX_ENTRIES_PER_DIR = 50;
36
+
37
+ /**
38
+ * Top-level entries hidden from the listing. Keep tight: anything not
39
+ * in this set is rendered. The check matches on the immediate child
40
+ * name under `/workspace/`.
41
+ */
42
+ const HIDDEN_TOP_LEVEL: ReadonlySet<string> = new Set(['.xema', '.opencode']);
43
+
44
+ /**
45
+ * Top-level directories whose child entries are intentionally not expanded.
46
+ *
47
+ * These locations are usually implicit/noisy (dependency caches, VCS internals,
48
+ * virtual environments) and rarely useful in the prompt body. We still show the
49
+ * top-level path so the model can request targeted tooling if needed.
50
+ */
51
+ const SKIP_CHILD_EXPANSION_TOP_LEVEL: ReadonlySet<string> = new Set([
52
+ '.git',
53
+ 'node_modules',
54
+ '.venv',
55
+ 'venv',
56
+ '__pycache__',
57
+ '.mypy_cache',
58
+ '.pytest_cache',
59
+ '.ruff_cache',
60
+ '.tox',
61
+ '.nox',
62
+ '.eggs',
63
+ ]);
64
+
65
+ interface ListedEntry {
66
+ readonly name: string;
67
+ readonly isDirectory: boolean;
68
+ }
69
+
70
+ export function renderWorkspaceListing(workspaceRoot: string): string {
71
+ const lines: string[] = [
72
+ '## Current workspace contents (depth 1)',
73
+ '',
74
+ 'This is a snapshot of `/workspace/` taken at the start of this turn. Use it to orient before reaching for `glob`, `list`, or `read`. Hidden: platform-private slots (`.xema/`, `.opencode/`).',
75
+ '',
76
+ ];
77
+
78
+ let topLevel: ListedEntry[];
79
+ try {
80
+ topLevel = readDirSorted(workspaceRoot);
81
+ } catch (err) {
82
+ // The hook is best-effort orientation. If the workspace can't be
83
+ // listed (extremely unusual — the workspace must exist for the
84
+ // session to have started), surface it inline rather than failing
85
+ // the whole chat call. The static overlay above this block already
86
+ // tells the LLM the canonical layout.
87
+ lines.push(`_Listing unavailable: ${(err as Error).message}_`);
88
+ return lines.join('\n');
89
+ }
90
+
91
+ const visible = topLevel.filter((entry) => !HIDDEN_TOP_LEVEL.has(entry.name));
92
+ if (visible.length === 0) {
93
+ lines.push('_(workspace is empty)_');
94
+ return lines.join('\n');
95
+ }
96
+
97
+ for (const entry of visible) {
98
+ lines.push(...renderTopLevelEntry(workspaceRoot, entry));
99
+ }
100
+
101
+ return lines.join('\n');
102
+ }
103
+
104
+ function renderTopLevelEntry(workspaceRoot: string, entry: ListedEntry): string[] {
105
+ const relPath = entry.name;
106
+ const absPath = join(workspaceRoot, entry.name);
107
+ if (!entry.isDirectory) {
108
+ return [`- \`/workspace/${relPath}\``];
109
+ }
110
+ if (SKIP_CHILD_EXPANSION_TOP_LEVEL.has(entry.name)) {
111
+ return [
112
+ `- \`/workspace/${relPath}/\` _(children omitted: known implicit/noisy directory — use \`glob\`/\`list\` if needed)_`,
113
+ ];
114
+ }
115
+
116
+ let children: ListedEntry[];
117
+ try {
118
+ children = readDirSorted(absPath);
119
+ } catch (err) {
120
+ return [`- \`/workspace/${relPath}/\` _(unreadable: ${(err as Error).message})_`];
121
+ }
122
+ if (children.length === 0) {
123
+ return [`- \`/workspace/${relPath}/\` _(empty)_`];
124
+ }
125
+
126
+ const shown = children.slice(0, MAX_ENTRIES_PER_DIR);
127
+ const childLines = shown.map((child) => {
128
+ const suffix = child.isDirectory ? '/' : '';
129
+ return ` - \`${child.name}${suffix}\``;
130
+ });
131
+ const overflow = children.length - shown.length;
132
+ const overflowLine =
133
+ overflow > 0
134
+ ? [` - _(${overflow} more entr${overflow === 1 ? 'y' : 'ies'} — use \`glob\`/\`list\` to enumerate)_`]
135
+ : [];
136
+
137
+ return [`- \`/workspace/${relPath}/\``, ...childLines, ...overflowLine];
138
+ }
139
+
140
+ /**
141
+ * `readdirSync` with deterministic ordering: directories first, then
142
+ * files, each group sorted by name (case-sensitive — workspace paths
143
+ * are case-sensitive on the worker filesystem). Symlinks resolve to
144
+ * their target's type via `statSync` so a symlinked dir lists like a
145
+ * dir, not a file.
146
+ */
147
+ function readDirSorted(dir: string): ListedEntry[] {
148
+ const dirents: Dirent[] = readdirSync(dir, { withFileTypes: true });
149
+ const entries: ListedEntry[] = dirents.map((d) => ({
150
+ name: d.name,
151
+ isDirectory: resolveIsDirectory(dir, d),
152
+ }));
153
+ entries.sort((a, b) => {
154
+ if (a.isDirectory !== b.isDirectory) {
155
+ return a.isDirectory ? -1 : 1;
156
+ }
157
+ if (a.name < b.name) {return -1;}
158
+ if (a.name > b.name) {return 1;}
159
+ return 0;
160
+ });
161
+ return entries;
162
+ }
163
+
164
+ function resolveIsDirectory(parent: string, dirent: Dirent): boolean {
165
+ if (dirent.isSymbolicLink()) {
166
+ try {
167
+ return statSync(join(parent, dirent.name)).isDirectory();
168
+ } catch {
169
+ // Broken symlink: classify as file so we surface its presence
170
+ // without crashing the listing.
171
+ return false;
172
+ }
173
+ }
174
+ return dirent.isDirectory();
175
+ }