@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/LICENSE +176 -175
- package/dist/context.d.ts +0 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -1
- package/dist/context.js.map +1 -1
- package/dist/hooks/session-idle-autocommit.js.map +1 -1
- package/dist/tools/xema-memory.d.ts.map +1 -1
- package/dist/tools/xema-memory.js.map +1 -1
- package/package.json +14 -4
- package/plugin.mjs +93 -89
- package/src/context.ts +154 -0
- package/src/errors.ts +24 -0
- package/src/hooks/session-idle-autocommit.ts +411 -0
- package/src/hooks/system-prompt-overlay.ts +99 -0
- package/src/hooks/workspace-listing.ts +175 -0
- package/src/index.ts +163 -0
- package/src/logger.ts +40 -0
- package/src/opencode-types.ts +107 -0
- package/src/plugin-entry.ts +22 -0
- package/src/session-state.ts +92 -0
- package/src/tools/runtime.ts +7 -0
- package/src/tools/service-token.ts +68 -0
- package/src/tools/shared.ts +16 -0
- package/src/tools/xema-memory.ts +232 -0
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
|
+
}
|