@skillstate/opencode 2.2.1 → 3.0.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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Sub-agent session registry for the OpenCode v2 plugin.
3
+ *
4
+ * OpenCode spawns a CHILD SESSION for every sub-agent (the `task` tool and
5
+ * every custom agent). Those child sessions are first-class: they run the
6
+ * agent loop, call tools, and would otherwise all write to the SAME project
7
+ * state file — the last writer wins and the main agent's notes are silently
8
+ * clobbered mid-task.
9
+ *
10
+ * The v1 plugin inferred this from an in-process `Map` fed by an `event`
11
+ * hook. v2 exposes a real event stream (`ctx.event.subscribe()`), so the
12
+ * registry is an explicit, testable value object instead of module-level
13
+ * mutable state shared by tests and a single plugin instance.
14
+ *
15
+ * PARENT EDGES come from the server's own event stream. In OpenCode v2 the
16
+ * relevant payloads are:
17
+ *
18
+ * ```ts
19
+ * { type: "session.created", data: { sessionID: string, parentID?: string } }
20
+ * { type: "session.forked", data: { sessionID: string, parentID: string } }
21
+ * { type: "session.deleted", data: { sessionID: string } }
22
+ * ```
23
+ *
24
+ * There is no `session.updated` event in v2, and the parent lives on
25
+ * `data.parentID` — not on a `properties.info` envelope as it did in v1.
26
+ * A session with a non-empty `parentID` is a sub-agent; a session with an
27
+ * absent or empty `parentID` is a root session. The registry never guesses
28
+ * from the id shape — a session that has not been observed on the stream
29
+ * yet is treated as a ROOT session (the safe default: it gets the shared
30
+ * project file, which is what a plain single-session user expects).
31
+ *
32
+ * LIFECYCLE: entries are refreshed on every event and evicted after
33
+ * {@link DEFAULT_SESSION_TTL_MS} of silence, so a long-lived OpenCode
34
+ * process does not accumulate one entry per session it has ever seen.
35
+ */
36
+ /** How long a session stays registered after its last event. */
37
+ export declare const DEFAULT_SESSION_TTL_MS: number;
38
+ /** A session as observed on the OpenCode event stream. */
39
+ export interface SessionRecord {
40
+ /** The host session id, verbatim. */
41
+ readonly id: string;
42
+ /** The parent session id, or `null` for a root session. */
43
+ readonly parentID: string | null;
44
+ /** Event receive time (epoch ms) — drives TTL eviction. */
45
+ readonly seenAt: number;
46
+ }
47
+ /**
48
+ * Options for {@link SessionRegistry}. Tests inject a clock and a TTL so
49
+ * eviction is deterministic.
50
+ */
51
+ export interface SessionRegistryOptions {
52
+ /** Clock in epoch milliseconds. Defaults to `Date.now`. */
53
+ now?: () => number;
54
+ /** Idle time after which a session is evicted. Defaults to 6h. */
55
+ ttlMs?: number;
56
+ /** Cap on retained sessions; the oldest are evicted first. */
57
+ maxSessions?: number;
58
+ }
59
+ /**
60
+ * Tracks the session→parent forest reported by the OpenCode event stream.
61
+ *
62
+ * Every method is pure with respect to the plugin's other state: the
63
+ * registry only ever answers questions about the shape of the session tree.
64
+ */
65
+ export declare class SessionRegistry {
66
+ private readonly sessions;
67
+ private readonly now;
68
+ private readonly ttlMs;
69
+ private readonly maxSessions;
70
+ constructor(options?: SessionRegistryOptions);
71
+ /**
72
+ * Fold one server event into the registry.
73
+ *
74
+ * `session.created` and `session.forked` register (or re-parent) a
75
+ * session; `session.deleted` forgets it. Any other event, and any
76
+ * malformed payload, is ignored — the stream carries far more than
77
+ * sessions and one odd record must never break the subscription loop.
78
+ *
79
+ * Returns the {@link SessionRecord} it wrote, or `null` when nothing was
80
+ * registered (including a deletion).
81
+ */
82
+ ingestEvent(event: unknown): SessionRecord | null;
83
+ /** The recorded session, or `undefined` when it was never observed. */
84
+ get(sessionID: string): SessionRecord | undefined;
85
+ /**
86
+ * Whether `sessionID` is a sub-agent session (a session with a parent).
87
+ *
88
+ * An UNOBSERVED session answers `false`: the plugin then treats it as a
89
+ * root session, which is the behaviour a single-session user expects and
90
+ * never creates a surprise `agents/` directory.
91
+ */
92
+ isSubAgent(sessionID: string): boolean;
93
+ /**
94
+ * The topmost ancestor of `sessionID` (itself when it is a root
95
+ * session). Follows parent edges with a visited set so a cycle — which a
96
+ * buggy or hostile event stream could produce — terminates instead of
97
+ * hanging the agent loop.
98
+ */
99
+ rootOf(sessionID: string): string;
100
+ /**
101
+ * Every session below `sessionID` in the tree - i.e. every session whose
102
+ * parent chain passes through `sessionID`, excluding `sessionID` itself.
103
+ *
104
+ * "Root ancestor" would be the wrong definition here: asked about a
105
+ * sub-agent it would also return that sub-agent, because the sub-agent's
106
+ * root is further up. Walking up to the queried session instead answers
107
+ * both cases uniformly, and a root session still gets every sub-agent.
108
+ *
109
+ * Sorted by id for a stable, testable result.
110
+ */
111
+ descendantsOf(sessionID: string): string[];
112
+ /**
113
+ * Whether `candidate` sits somewhere below `ancestor`. Cycle-safe: a
114
+ * parent chain that loops back on itself terminates instead of hanging
115
+ * the agent loop.
116
+ */
117
+ private isDescendantOf;
118
+ /** Every registered session, oldest-observed first. */
119
+ list(): readonly SessionRecord[];
120
+ /** How many sessions are currently registered. */
121
+ get size(): number;
122
+ /** Forget every session (test isolation, and a clean reload). */
123
+ clear(): void;
124
+ /**
125
+ * Drop sessions idle for longer than the TTL, then enforce the size cap
126
+ * by dropping the least-recently-seen entries. Called after every
127
+ * ingest; also exposed for tests.
128
+ */
129
+ evict(): void;
130
+ }
131
+ /**
132
+ * The on-disk scope name for a session: a stable, filesystem-safe id that
133
+ * keeps SUB-AGENT sessions in their own directory and root sessions on the
134
+ * shared project file.
135
+ *
136
+ * Returns `''` for a root session (the shared project state) and a
137
+ * sanitized `<parent>-<session>` id for a sub-agent.
138
+ *
139
+ * The child part is the FULL sanitized session id, not a short prefix. The
140
+ * v1 code truncated to 8 characters, which silently collapses two sibling
141
+ * sub-agents into one state file whenever their ids share a prefix — they
142
+ * overwrite each other's notes with no error. `sanitizeAgentId` caps the
143
+ * result at 64 characters, so the directory name stays bounded. The parent
144
+ * part is truncated to 8 characters because it only has to disambiguate
145
+ * which root a sub-agent belongs to.
146
+ */
147
+ export declare function stateScopeFor(registry: SessionRegistry, sessionID: string): string;
148
+ //# sourceMappingURL=session-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-registry.d.ts","sourceRoot":"","sources":["../src/session-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAIH,gEAAgE;AAChE,eAAO,MAAM,sBAAsB,QAAqB,CAAC;AAEzD,0DAA0D;AAC1D,MAAM,WAAW,aAAa;IAC5B,qCAAqC;IACrC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACrC,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,kEAAkE;IAClE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAkCD;;;;;GAKG;AACH,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoC;IAC7D,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IAErC,YAAY,OAAO,GAAE,sBAA2B,EAI/C;IAED;;;;;;;;;;OAUG;IACH,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CAehD;IAED,uEAAuE;IACvE,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAEhD;IAED;;;;;;OAMG;IACH,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAErC;IAED;;;;;OAKG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAUhC;IAED;;;;;;;;;;OAUG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAQzC;IAED;;;;OAIG;IACH,OAAO,CAAC,cAAc;IActB,uDAAuD;IACvD,IAAI,IAAI,SAAS,aAAa,EAAE,CAE/B;IAED,kDAAkD;IAClD,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,iEAAiE;IACjE,KAAK,IAAI,IAAI,CAEZ;IAED;;;;OAIG;IACH,KAAK,IAAI,IAAI,CAYZ;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,eAAe,EACzB,SAAS,EAAE,MAAM,GAChB,MAAM,CAQR"}
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Sub-agent session registry for the OpenCode v2 plugin.
3
+ *
4
+ * OpenCode spawns a CHILD SESSION for every sub-agent (the `task` tool and
5
+ * every custom agent). Those child sessions are first-class: they run the
6
+ * agent loop, call tools, and would otherwise all write to the SAME project
7
+ * state file — the last writer wins and the main agent's notes are silently
8
+ * clobbered mid-task.
9
+ *
10
+ * The v1 plugin inferred this from an in-process `Map` fed by an `event`
11
+ * hook. v2 exposes a real event stream (`ctx.event.subscribe()`), so the
12
+ * registry is an explicit, testable value object instead of module-level
13
+ * mutable state shared by tests and a single plugin instance.
14
+ *
15
+ * PARENT EDGES come from the server's own event stream. In OpenCode v2 the
16
+ * relevant payloads are:
17
+ *
18
+ * ```ts
19
+ * { type: "session.created", data: { sessionID: string, parentID?: string } }
20
+ * { type: "session.forked", data: { sessionID: string, parentID: string } }
21
+ * { type: "session.deleted", data: { sessionID: string } }
22
+ * ```
23
+ *
24
+ * There is no `session.updated` event in v2, and the parent lives on
25
+ * `data.parentID` — not on a `properties.info` envelope as it did in v1.
26
+ * A session with a non-empty `parentID` is a sub-agent; a session with an
27
+ * absent or empty `parentID` is a root session. The registry never guesses
28
+ * from the id shape — a session that has not been observed on the stream
29
+ * yet is treated as a ROOT session (the safe default: it gets the shared
30
+ * project file, which is what a plain single-session user expects).
31
+ *
32
+ * LIFECYCLE: entries are refreshed on every event and evicted after
33
+ * {@link DEFAULT_SESSION_TTL_MS} of silence, so a long-lived OpenCode
34
+ * process does not accumulate one entry per session it has ever seen.
35
+ */
36
+ import { sanitizeAgentId } from '@skillstate/core';
37
+ /** How long a session stays registered after its last event. */
38
+ export const DEFAULT_SESSION_TTL_MS = 6 * 60 * 60 * 1000;
39
+ const DEFAULT_MAX_SESSIONS = 512;
40
+ function readSessionEvent(event) {
41
+ if (typeof event !== 'object' || event === null)
42
+ return null;
43
+ const type = event.type;
44
+ if (typeof type !== 'string')
45
+ return null;
46
+ if (type !== 'session.created' && type !== 'session.forked' && type !== 'session.deleted') {
47
+ return null;
48
+ }
49
+ const data = event.data;
50
+ if (typeof data !== 'object' || data === null)
51
+ return null;
52
+ const record = data;
53
+ if (typeof record.sessionID !== 'string' || record.sessionID.length === 0)
54
+ return null;
55
+ if (type === 'session.deleted')
56
+ return { kind: 'delete', id: record.sessionID };
57
+ const parent = typeof record.parentID === 'string' && record.parentID.length > 0 ? record.parentID : null;
58
+ return { kind: 'upsert', id: record.sessionID, parentID: parent };
59
+ }
60
+ /**
61
+ * Tracks the session→parent forest reported by the OpenCode event stream.
62
+ *
63
+ * Every method is pure with respect to the plugin's other state: the
64
+ * registry only ever answers questions about the shape of the session tree.
65
+ */
66
+ export class SessionRegistry {
67
+ sessions = new Map();
68
+ now;
69
+ ttlMs;
70
+ maxSessions;
71
+ constructor(options = {}) {
72
+ this.now = options.now ?? Date.now;
73
+ this.ttlMs = options.ttlMs ?? DEFAULT_SESSION_TTL_MS;
74
+ this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
75
+ }
76
+ /**
77
+ * Fold one server event into the registry.
78
+ *
79
+ * `session.created` and `session.forked` register (or re-parent) a
80
+ * session; `session.deleted` forgets it. Any other event, and any
81
+ * malformed payload, is ignored — the stream carries far more than
82
+ * sessions and one odd record must never break the subscription loop.
83
+ *
84
+ * Returns the {@link SessionRecord} it wrote, or `null` when nothing was
85
+ * registered (including a deletion).
86
+ */
87
+ ingestEvent(event) {
88
+ const parsed = readSessionEvent(event);
89
+ if (parsed === null)
90
+ return null;
91
+ if (parsed.kind === 'delete') {
92
+ this.sessions.delete(parsed.id);
93
+ return null;
94
+ }
95
+ const record = {
96
+ id: parsed.id,
97
+ parentID: parsed.parentID,
98
+ seenAt: this.now(),
99
+ };
100
+ this.sessions.set(record.id, record);
101
+ this.evict();
102
+ return record;
103
+ }
104
+ /** The recorded session, or `undefined` when it was never observed. */
105
+ get(sessionID) {
106
+ return this.sessions.get(sessionID);
107
+ }
108
+ /**
109
+ * Whether `sessionID` is a sub-agent session (a session with a parent).
110
+ *
111
+ * An UNOBSERVED session answers `false`: the plugin then treats it as a
112
+ * root session, which is the behaviour a single-session user expects and
113
+ * never creates a surprise `agents/` directory.
114
+ */
115
+ isSubAgent(sessionID) {
116
+ return this.sessions.get(sessionID)?.parentID != null;
117
+ }
118
+ /**
119
+ * The topmost ancestor of `sessionID` (itself when it is a root
120
+ * session). Follows parent edges with a visited set so a cycle — which a
121
+ * buggy or hostile event stream could produce — terminates instead of
122
+ * hanging the agent loop.
123
+ */
124
+ rootOf(sessionID) {
125
+ let current = sessionID;
126
+ const visited = new Set([sessionID]);
127
+ for (;;) {
128
+ const record = this.sessions.get(current);
129
+ const parent = record?.parentID;
130
+ if (parent === undefined || parent === null || visited.has(parent))
131
+ return current;
132
+ visited.add(parent);
133
+ current = parent;
134
+ }
135
+ }
136
+ /**
137
+ * Every session below `sessionID` in the tree - i.e. every session whose
138
+ * parent chain passes through `sessionID`, excluding `sessionID` itself.
139
+ *
140
+ * "Root ancestor" would be the wrong definition here: asked about a
141
+ * sub-agent it would also return that sub-agent, because the sub-agent's
142
+ * root is further up. Walking up to the queried session instead answers
143
+ * both cases uniformly, and a root session still gets every sub-agent.
144
+ *
145
+ * Sorted by id for a stable, testable result.
146
+ */
147
+ descendantsOf(sessionID) {
148
+ const result = [];
149
+ for (const record of this.sessions.values()) {
150
+ if (record.id !== sessionID && this.isDescendantOf(record.id, sessionID)) {
151
+ result.push(record.id);
152
+ }
153
+ }
154
+ return result.sort();
155
+ }
156
+ /**
157
+ * Whether `candidate` sits somewhere below `ancestor`. Cycle-safe: a
158
+ * parent chain that loops back on itself terminates instead of hanging
159
+ * the agent loop.
160
+ */
161
+ isDescendantOf(candidate, ancestor) {
162
+ let current = candidate;
163
+ const visited = new Set([candidate]);
164
+ for (;;) {
165
+ const record = this.sessions.get(current);
166
+ const parent = record?.parentID ?? null;
167
+ if (parent === null)
168
+ return false;
169
+ if (parent === ancestor)
170
+ return true;
171
+ if (visited.has(parent))
172
+ return false;
173
+ visited.add(parent);
174
+ current = parent;
175
+ }
176
+ }
177
+ /** Every registered session, oldest-observed first. */
178
+ list() {
179
+ return [...this.sessions.values()].sort((a, b) => a.seenAt - b.seenAt || a.id.localeCompare(b.id));
180
+ }
181
+ /** How many sessions are currently registered. */
182
+ get size() {
183
+ return this.sessions.size;
184
+ }
185
+ /** Forget every session (test isolation, and a clean reload). */
186
+ clear() {
187
+ this.sessions.clear();
188
+ }
189
+ /**
190
+ * Drop sessions idle for longer than the TTL, then enforce the size cap
191
+ * by dropping the least-recently-seen entries. Called after every
192
+ * ingest; also exposed for tests.
193
+ */
194
+ evict() {
195
+ const cutoff = this.now() - this.ttlMs;
196
+ for (const [id, record] of this.sessions) {
197
+ if (record.seenAt < cutoff)
198
+ this.sessions.delete(id);
199
+ }
200
+ if (this.sessions.size <= this.maxSessions)
201
+ return;
202
+ const ordered = [...this.sessions.entries()].sort((a, b) => a[1].seenAt - b[1].seenAt || a[0].localeCompare(b[0]));
203
+ for (const [id] of ordered.slice(0, this.sessions.size - this.maxSessions)) {
204
+ this.sessions.delete(id);
205
+ }
206
+ }
207
+ }
208
+ /**
209
+ * The on-disk scope name for a session: a stable, filesystem-safe id that
210
+ * keeps SUB-AGENT sessions in their own directory and root sessions on the
211
+ * shared project file.
212
+ *
213
+ * Returns `''` for a root session (the shared project state) and a
214
+ * sanitized `<parent>-<session>` id for a sub-agent.
215
+ *
216
+ * The child part is the FULL sanitized session id, not a short prefix. The
217
+ * v1 code truncated to 8 characters, which silently collapses two sibling
218
+ * sub-agents into one state file whenever their ids share a prefix — they
219
+ * overwrite each other's notes with no error. `sanitizeAgentId` caps the
220
+ * result at 64 characters, so the directory name stays bounded. The parent
221
+ * part is truncated to 8 characters because it only has to disambiguate
222
+ * which root a sub-agent belongs to.
223
+ */
224
+ export function stateScopeFor(registry, sessionID) {
225
+ const record = registry.get(sessionID);
226
+ if (record === undefined || record.parentID === null)
227
+ return '';
228
+ const child = sanitizeAgentId(sessionID);
229
+ const parentPrefix = sanitizeAgentId(record.parentID).slice(0, 8);
230
+ if (child.length === 0)
231
+ return parentPrefix;
232
+ if (parentPrefix.length === 0)
233
+ return child;
234
+ return `${parentPrefix}-${child}`;
235
+ }
236
+ //# sourceMappingURL=session-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-registry.js","sourceRoot":"","sources":["../src/session-registry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Project state persistence for the OpenCode v2 plugin.
3
+ *
4
+ * ONE store, THREE guarantees:
5
+ *
6
+ * 1. **Per-project addressing.** The state file is derived from the plugin
7
+ * instance's location (`ctx.location.project.canonical`), never from
8
+ * `process.cwd()`. The v1 plugin called `process.cwd()` on every hook;
9
+ * in v2 a single OpenCode server serves many projects, so the process
10
+ * cwd is simply the wrong answer. Two checkouts no longer share state.
11
+ *
12
+ * 2. **Per-session isolation.** A root session owns the shared project
13
+ * file; a sub-agent session owns `agents/<scope>/skillstate.json` (see
14
+ * `stateScopeFor`). Parallel sub-agents therefore never last-writer-win
15
+ * over the main session's notes.
16
+ *
17
+ * 3. **Never lose a write.** Every mutation runs inside the core
18
+ * cross-process lock ({@link withStateLock}) and lands via
19
+ * {@link atomicWriteFile} (temp sibling → fsync → rename), so a crash
20
+ * mid-write can never leave a truncated or interleaved state file. Reads
21
+ * never throw: a missing or corrupt file reads as `{}`.
22
+ *
23
+ * The store is deliberately schema-free. It does not validate against a
24
+ * procedural spec: OpenCode sessions are free-form work, and forcing a
25
+ * fixed `goal`/`progress`/`next_steps` shape is what made the v1 prompt
26
+ * rewrite the user's actual task into a state machine.
27
+ */
28
+ import type { StatePatch } from '@skillstate/core';
29
+ /** A skillstate document: a plain JSON object. */
30
+ export type StateDocument = Record<string, unknown>;
31
+ /** Options for {@link ProjectStateStore}. */
32
+ export interface ProjectStateStoreOptions {
33
+ /**
34
+ * The project directory the state file lives under. Prefer
35
+ * `ctx.location.project.canonical` — the canonical checkout, stable
36
+ * across worktrees.
37
+ */
38
+ directory: string;
39
+ /** Home directory, for the `$HOME` global bucket. Defaults to `os.homedir()`. */
40
+ home?: string;
41
+ }
42
+ /**
43
+ * The keys a patch actually changed, split by direction. Mirrors the
44
+ * `state.patch` MCP contract so behaviour is identical across hosts.
45
+ */
46
+ export interface StateChanges {
47
+ readonly added: readonly string[];
48
+ readonly updated: readonly string[];
49
+ readonly deleted: readonly string[];
50
+ }
51
+ /** Top-level diff between two state documents. Pure. */
52
+ export declare function diffDocuments(before: StateDocument, after: StateDocument): StateChanges;
53
+ /**
54
+ * The state file for one session scope inside one project.
55
+ *
56
+ * `scope === ''` is the shared project file; any other scope is a
57
+ * sub-agent's isolated copy. Delegates to the core resolver (shared with
58
+ * the MCP server, the CLI and the other host adapters) so every host agrees
59
+ * on where a project's state lives.
60
+ */
61
+ export declare function statePathFor(directory: string, scope: string, home?: string): string;
62
+ /**
63
+ * Reads and merges the per-project state documents for a plugin instance.
64
+ *
65
+ * Stateless between calls apart from the filesystem, so it is safe to keep
66
+ * one instance per plugin and to construct it in tests without any global
67
+ * setup.
68
+ */
69
+ export declare class ProjectStateStore {
70
+ private readonly directory;
71
+ private readonly home;
72
+ constructor(options: ProjectStateStoreOptions);
73
+ /** The project directory this store addresses. */
74
+ get projectDirectory(): string;
75
+ /** The absolute state file path for a scope (`''` = shared). */
76
+ pathFor(scope: string): string;
77
+ /**
78
+ * Whether a state file already exists for this scope. The plugin uses
79
+ * this to decide whether to inject the system hint at all — an untouched
80
+ * project must behave exactly like vanilla OpenCode.
81
+ */
82
+ exists(scope: string): boolean;
83
+ /**
84
+ * Read a scope's state. A missing, unreadable or corrupt file reads as
85
+ * `{}` — a best-effort read that never breaks the agent loop.
86
+ */
87
+ read(scope: string): StateDocument;
88
+ /**
89
+ * Apply a patch to a scope's state and persist it.
90
+ *
91
+ * The read, the merge and the write all happen inside the cross-process
92
+ * lock, so two sub-agents writing disjoint keys both survive — neither
93
+ * can clobber the other's write by reading a stale snapshot.
94
+ *
95
+ * Returns the merged document and the top-level change sets. A `null`
96
+ * value in the patch deletes that key (paper ⊕).
97
+ */
98
+ patch(scope: string, patch: StatePatch): Promise<{
99
+ state: StateDocument;
100
+ changes: StateChanges;
101
+ }>;
102
+ /**
103
+ * Fold a sub-agent's document into this scope's document.
104
+ *
105
+ * `keep` decides what happens when both sides set the same scalar:
106
+ * `'existing'` (default) keeps the receiving document's value, `'source'`
107
+ * takes the sub-agent's. Keys only present in the sub-agent are always
108
+ * taken, and `null` in either document is a deletion.
109
+ *
110
+ * The source document is left on disk — the merge is not destructive, so
111
+ * a sub-agent's notes remain inspectable after the fold.
112
+ */
113
+ merge(scope: string, sourceScope: string, keep?: 'existing' | 'source'): Promise<{
114
+ state: StateDocument;
115
+ changes: StateChanges;
116
+ }>;
117
+ }
118
+ /**
119
+ * Fold `source` into `target` under a conflict policy. Pure.
120
+ *
121
+ * Nested plain objects recurse. A `null` on either side is a deletion and
122
+ * wins outright — an explicit "remove this" is not a scalar conflict. Every
123
+ * other scalar conflict follows `keep`.
124
+ */
125
+ export declare function mergeDocuments(target: StateDocument, source: StateDocument, keep: 'existing' | 'source'): StateDocument;
126
+ //# sourceMappingURL=state-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-store.d.ts","sourceRoot":"","sources":["../src/state-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAaH,OAAO,KAAK,EAAc,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAE/D,kDAAkD;AAClD,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEpD,6CAA6C;AAC7C,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,wDAAwD;AACxD,wBAAgB,aAAa,CAC3B,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,aAAa,GACnB,YAAY,CAed;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,MAAqB,GAC1B,MAAM,CAER;AAED;;;;;;GAMG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAE9B,YAAY,OAAO,EAAE,wBAAwB,EAG5C;IAED,kDAAkD;IAClD,IAAI,gBAAgB,IAAI,MAAM,CAE7B;IAED,gEAAgE;IAChE,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE7B;IAED;;;;OAIG;IACH,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAM7B;IAED;;;OAGG;IACH,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,CAQjC;IAED;;;;;;;;;OASG;IACG,KAAK,CACT,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,UAAU,GAChB,OAAO,CAAC;QAAE,KAAK,EAAE,aAAa,CAAC;QAAC,OAAO,EAAE,YAAY,CAAA;KAAE,CAAC,CAY1D;IAED;;;;;;;;;;OAUG;IACG,KAAK,CACT,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,IAAI,GAAE,UAAU,GAAG,QAAqB,GACvC,OAAO,CAAC;QAAE,KAAK,EAAE,aAAa,CAAC;QAAC,OAAO,EAAE,YAAY,CAAA;KAAE,CAAC,CAoB1D;CACF;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,aAAa,EACrB,IAAI,EAAE,UAAU,GAAG,QAAQ,GAC1B,aAAa,CAsBf"}