@xemahq/opencode-xema-plugin 0.1.3 → 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.
package/src/context.ts ADDED
@@ -0,0 +1,154 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── /workspace/context.json reader ──
3
+ // Read once per tool/hook invocation. Surfaces invocation identity (role,
4
+ // surface, runId, sessionId) and the per-invocation `authority` block to
5
+ // the plugin's tools and the system-prompt overlay hook.
6
+ // ═══════════════════════════════════════════════════════════════════════════
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { z } from 'zod';
11
+
12
+ import { warn } from './logger.js';
13
+
14
+ const ContextJsonSchema = z.object({
15
+ invocation: z.object({
16
+ runId: z.string().nullable().optional(),
17
+ sessionId: z.string().nullable().optional(),
18
+ attempt: z.number().int().min(1).default(1),
19
+ role: z.enum([
20
+ 'unit-worker',
21
+ 'coordinator',
22
+ 'gate-reviewer',
23
+ 'clarification-coordinator',
24
+ 'scope-validator',
25
+ 'agent-session',
26
+ 'design-system-builder',
27
+ 'engineer',
28
+ 'generic-agent',
29
+ 'auditor',
30
+ // Document Buddy: agent collaborates on a single KB document page
31
+ // by editing a real working file (`/workspace/document/<slug>.<ext>`)
32
+ // natively; workspace-proxy's document-sync component derives the
33
+ // reviewable change set from those edits.
34
+ 'document',
35
+ ]),
36
+ surface: z
37
+ .enum(['pipeline-run', 'agent-session', 'design-system-builder', 'gate-review'])
38
+ .optional(),
39
+ }),
40
+ project: z
41
+ .object({
42
+ orgId: z.string(),
43
+ projectId: z.string().nullish(),
44
+ // Pipeline invocations outside a run (interactive sessions,
45
+ // brainstorming, gate reviews not tied to a specific branch)
46
+ // emit `pipelineBranch: null` — distinct from "absent". Accept
47
+ // both null and undefined here so the context validation survives.
48
+ pipelineBranch: z.string().nullish(),
49
+ })
50
+ .nullish(),
51
+ authority: z.object({
52
+ mayWriteWorkspace: z.array(z.string()).default(['/workspace/deliverables']),
53
+ mayWriteSlugs: z.array(z.string()).default([]),
54
+ mayWriteKnowledgeBase: z.boolean().default(true),
55
+ mayEditRepos: z.boolean().default(false),
56
+ writePolicy: z.enum(['read-only', 'own-pages-only', 'full-write']),
57
+ /**
58
+ * Closed list of Xema-namespaced tool names this invocation may call.
59
+ * Resolved from the `RoleCapabilityProfile` for `invocation.role` by
60
+ * llm-registry-api's `agent-run-context.service.ts` and written into
61
+ * context.json verbatim. `session-state.ensureRoleAllowed` checks
62
+ * every Xema tool call against this list — no fallback to a per-role
63
+ * registry baked into the plugin.
64
+ */
65
+ allowedXemaTools: z.array(z.string()).min(1),
66
+ }),
67
+ /**
68
+ * Optional `git` block consumed by the `session.idle` auto-commit hook
69
+ * (see `hooks/session-idle-autocommit.ts`). Written by the orchestrator
70
+ * when the session's `BranchMode` opts into per-turn auto-commit
71
+ * (`shared_dev_publish_to_prod` — the default). Absent / `disabled` for
72
+ * `branch_per_session` sessions, where commits are user-controlled.
73
+ *
74
+ * `repoSubPath` is the working repo's path relative to the workspace
75
+ * root. When omitted, the hook commits at the workspace root itself
76
+ * (single-repo workspace contract — multi-repo workspaces are handled
77
+ * by a future follow-up; see TODO(multi-repo) in the
78
+ * hook).
79
+ */
80
+ git: z
81
+ .object({
82
+ autoCommit: z.enum(['enabled', 'disabled']),
83
+ repoSubPath: z.string().nullish(),
84
+ })
85
+ .nullish(),
86
+ /**
87
+ * Optional `actor` block consumed by the `session.idle` auto-commit
88
+ * hook. Mirrors `ActorRefDto` in workspace-git-api: the subject that
89
+ * triggered the turn. Stamped at session-start by the orchestrator
90
+ * (single-user binding) or rotated per-turn by the multi-user session
91
+ * extension. Absent for non-session invocations (pipeline-run agents
92
+ * never auto-commit).
93
+ */
94
+ actor: z
95
+ .object({
96
+ kind: z.enum(['org_user']),
97
+ subjectRef: z.string().min(1),
98
+ displayName: z.string().min(1),
99
+ })
100
+ .nullish(),
101
+ });
102
+
103
+ export type InvocationContext = z.infer<typeof ContextJsonSchema>;
104
+ export type InvocationRole = InvocationContext['invocation']['role'];
105
+
106
+ /**
107
+ * Read and validate /workspace/context.json. Returns null if the file is
108
+ * absent — the plugin then registers no Xema-specific tools (non-Xema
109
+ * sessions simply pass through). Throws on malformed JSON or schema
110
+ * violations so programmatic consumers (tests, tools) see the underlying
111
+ * error; hooks call {@link readInvocationContextForHook} instead to get
112
+ * a log-and-skip shape.
113
+ */
114
+ export function readInvocationContext(
115
+ workspaceDir: string,
116
+ ): InvocationContext | null {
117
+ const path = join(workspaceDir, 'context.json');
118
+ if (!existsSync(path)) {
119
+ return null;
120
+ }
121
+ const raw = readFileSync(path, 'utf-8');
122
+ const parsed = JSON.parse(raw) as unknown;
123
+ return ContextJsonSchema.parse(parsed);
124
+ }
125
+
126
+ /**
127
+ * Hook-friendly variant of {@link readInvocationContext}. Silently returns
128
+ * `null` when `context.json` is absent (expected for non-Xema sessions /
129
+ * pre-mount calls), and emits a prefixed warn to stderr when the file is
130
+ * present but malformed — previously these errors propagated into
131
+ * opencode's hook dispatcher and were swallowed by the runtime, leaving
132
+ * operators with no signal that the plugin had gone blind mid-session.
133
+ */
134
+ export function readInvocationContextForHook(
135
+ workspaceDir: string,
136
+ hookName: string,
137
+ ): InvocationContext | null {
138
+ const path = join(workspaceDir, 'context.json');
139
+ if (!existsSync(path)) {
140
+ return null;
141
+ }
142
+ try {
143
+ const raw = readFileSync(path, 'utf-8');
144
+ const parsed = JSON.parse(raw) as unknown;
145
+ return ContextJsonSchema.parse(parsed);
146
+ } catch (err) {
147
+ const message = err instanceof Error ? err.message : String(err);
148
+ warn(
149
+ `hook=${hookName}: failed to read/validate ${path}: ${message}. ` +
150
+ `Hook will skip this invocation; fix context.json to re-enable plugin behavior.`,
151
+ );
152
+ return null;
153
+ }
154
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,24 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── Plugin error types ──
3
+ // Typed errors surfaced to the LLM as tool results. Opencode naturally
4
+ // retries tool errors in the same session, so these are our in-session
5
+ // correction signals.
6
+ // ═══════════════════════════════════════════════════════════════════════════
7
+
8
+ /**
9
+ * Thrown by the `experimental.chat.system.transform` hook when a Xema
10
+ * session boots but `/workspace/.xema/system-overlay.md` is missing.
11
+ *
12
+ * Fail-closed: the run's platform contract is undefined; we refuse to
13
+ * start the chat rather than letting the agent operate without the AWP
14
+ * base layer + deliverable contract + authority guidance the rest of
15
+ * the platform assumes is in scope.
16
+ */
17
+ export class XemaSystemOverlayMissingError extends Error {
18
+ readonly code = 'XEMA_SYSTEM_OVERLAY_MISSING';
19
+ constructor(message: string) {
20
+ super(message);
21
+ this.name = 'XemaSystemOverlayMissingError';
22
+ }
23
+ }
24
+
@@ -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
+ }