opencode-stm 0.0.0-stage → 0.2.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,596 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { tool } from "@opencode-ai/plugin";
3
+ import { initialMeta, hashTitle } from "./state.js";
4
+ import { page, readSession, writeTitle } from "./sdk.js";
5
+ import { extractVisibleText } from "./transcript.js";
6
+ import { firstObservation, isDefaultTitle, lockExternal, lockManually, resolveReview, unlock } from "./scheduler.js";
7
+ export const TITLE_MAX_CODE_POINTS = 120;
8
+ const TITLE_DISPLAY_MAX = 200;
9
+ const CONTROL_CHARACTERS = /[\p{Cc}\u2028\u2029]/u;
10
+ export function validateTitle(raw) {
11
+ const value = raw.trim();
12
+ if (value.length === 0)
13
+ return { ok: false, reason: "title-empty" };
14
+ if ([...value].length > TITLE_MAX_CODE_POINTS)
15
+ return { ok: false, reason: "title-too-long" };
16
+ if (CONTROL_CHARACTERS.test(value))
17
+ return { ok: false, reason: "title-control-characters" };
18
+ return { ok: true, value };
19
+ }
20
+ function status(value) {
21
+ return JSON.stringify({ plugin: "opencode-stm", ...value });
22
+ }
23
+ function titleForStatus(title) {
24
+ const points = [...title];
25
+ if (points.length <= TITLE_DISPLAY_MAX)
26
+ return { value: title, truncated: false };
27
+ return { value: `${points.slice(0, TITLE_DISPLAY_MAX).join("")}…`, truncated: true };
28
+ }
29
+ async function requireRootCaller(services, context) {
30
+ const root = await services.roots.isRoot(context.sessionID, context.abort);
31
+ if (!root.ok)
32
+ return { ok: false, status: "error" };
33
+ if (!root.value)
34
+ return { ok: false, status: "unsupported-child-session" };
35
+ return { ok: true };
36
+ }
37
+ async function askOrAbort(context, permission, target, metadata) {
38
+ if (context.abort.aborted)
39
+ return { ok: false, status: "aborted" };
40
+ try {
41
+ await context.ask({ permission, patterns: [target], always: [], metadata });
42
+ }
43
+ catch {
44
+ return { ok: false, status: context.abort.aborted ? "aborted" : "denied" };
45
+ }
46
+ if (context.abort.aborted)
47
+ return { ok: false, status: "aborted" };
48
+ return { ok: true };
49
+ }
50
+ export async function establishBaseline(services, sessionID, signal, knownTitle) {
51
+ const loaded = await services.state.load(sessionID);
52
+ if (!loaded.ok)
53
+ return { ok: false, reason: loaded.reason };
54
+ if (loaded.value !== null)
55
+ return { ok: true, meta: loaded.value };
56
+ let title = knownTitle;
57
+ if (title === undefined) {
58
+ const read = await readSession(services.client, sessionID, signal);
59
+ if (!read.ok)
60
+ return { ok: false, reason: read.reason };
61
+ title = read.value.title;
62
+ }
63
+ const meta = firstObservation(isDefaultTitle(title), hashTitle(title));
64
+ // Transition atomically against the latest persisted metadata: a concurrent
65
+ // lock (or any other write) that landed since the initial load must win
66
+ // instead of being overwritten by this baseline.
67
+ const updated = await services.state.update(sessionID, (current) => {
68
+ const next = current ?? meta;
69
+ return { next, result: next };
70
+ });
71
+ if (!updated.ok)
72
+ return { ok: false, reason: updated.reason };
73
+ return { ok: true, meta: updated.value };
74
+ }
75
+ export function createCurrentTools(services) {
76
+ const titleRead = tool({
77
+ description: "Read the current session title, lock state and pending title review ID.",
78
+ args: {},
79
+ async execute(_args, context) {
80
+ if (context.abort.aborted)
81
+ return status({ status: "aborted" });
82
+ const caller = await requireRootCaller(services, context);
83
+ if (!caller.ok)
84
+ return status({ status: caller.status });
85
+ const read = await readSession(services.client, context.sessionID, context.abort);
86
+ if (!read.ok)
87
+ return status({ status: "error" });
88
+ const baseline = await establishBaseline(services, context.sessionID, context.abort, read.value.title);
89
+ if (!baseline.ok)
90
+ return status({ status: "state-unavailable" });
91
+ const shown = titleForStatus(read.value.title);
92
+ return status({
93
+ status: "ok",
94
+ sessionID: context.sessionID,
95
+ title: shown.value,
96
+ titleTruncated: shown.truncated,
97
+ locked: baseline.meta.locked,
98
+ lockReason: baseline.meta.lockReason,
99
+ reviewID: baseline.meta.review?.id ?? null,
100
+ });
101
+ },
102
+ });
103
+ const titleReview = tool({
104
+ description: "Resolve the current title review, or apply an explicit keep/rename decision. Keep requires no title; rename requires a new title.",
105
+ args: {
106
+ decision: tool.schema.enum(["keep", "rename"]),
107
+ expectedTitle: tool.schema.string(),
108
+ title: tool.schema.optional(tool.schema.string()),
109
+ reviewID: tool.schema.optional(tool.schema.string()),
110
+ },
111
+ async execute(args, context) {
112
+ if (context.abort.aborted)
113
+ return status({ status: "aborted" });
114
+ const caller = await requireRootCaller(services, context);
115
+ if (!caller.ok)
116
+ return status({ status: caller.status });
117
+ let newTitle = null;
118
+ if (args.decision === "rename") {
119
+ if (args.title === undefined)
120
+ return status({ status: "invalid", reason: "title-required" });
121
+ const valid = validateTitle(args.title);
122
+ if (!valid.ok)
123
+ return status({ status: "invalid", reason: valid.reason });
124
+ newTitle = valid.value;
125
+ }
126
+ else if (args.title !== undefined) {
127
+ return status({ status: "invalid", reason: "title-not-allowed" });
128
+ }
129
+ const loaded = await services.state.load(context.sessionID);
130
+ if (!loaded.ok)
131
+ return status({ status: "state-unavailable" });
132
+ const read = await readSession(services.client, context.sessionID, context.abort);
133
+ if (!read.ok)
134
+ return status({ status: "error" });
135
+ const currentTitle = read.value.title;
136
+ let meta = loaded.value;
137
+ if (meta === null) {
138
+ const baseline = await establishBaseline(services, context.sessionID, context.abort, currentTitle);
139
+ if (!baseline.ok)
140
+ return status({ status: "state-unavailable" });
141
+ meta = baseline.meta;
142
+ }
143
+ if (args.reviewID !== undefined && (meta.review === null || meta.review.id !== args.reviewID)) {
144
+ return status({ status: "conflict", reason: "stale-review" });
145
+ }
146
+ if (currentTitle !== args.expectedTitle) {
147
+ return status({ status: "conflict", reason: "expected-title-mismatch" });
148
+ }
149
+ if (meta.titleDigest !== null && hashTitle(currentTitle) !== meta.titleDigest) {
150
+ // The comparison above used the pre-read snapshot: a concurrent unlock
151
+ // (or any other transition) may have established the current digest
152
+ // while that read was pending. Re-evaluate the mismatch under the same
153
+ // guard as the mutating tools, against the latest persisted state and a
154
+ // fresh title read: only an unattributed difference that is still
155
+ // present on the latest state locks the session; a verified explicit
156
+ // unlock is preserved and an already-locked state is never overwritten.
157
+ const external = await services.guard.run(context.sessionID, async () => {
158
+ if (context.abort.aborted)
159
+ return { ok: false, status: "aborted" };
160
+ const read = await readSession(services.client, context.sessionID, context.abort);
161
+ if (context.abort.aborted)
162
+ return { ok: false, status: "aborted" };
163
+ if (!read.ok)
164
+ return { ok: false, status: "error" };
165
+ const saved = await services.state.update(context.sessionID, (current) => {
166
+ const base = current ?? meta;
167
+ if (base.locked)
168
+ return { next: base, result: true };
169
+ if (base.titleDigest === null || hashTitle(read.value.title) === base.titleDigest) {
170
+ return { next: base, result: false };
171
+ }
172
+ return { next: lockExternal(base), result: true };
173
+ });
174
+ if (!saved.ok)
175
+ return { ok: false, status: "uncertain" };
176
+ return { ok: true, locked: saved.value };
177
+ });
178
+ if (!external.ok) {
179
+ if (external.status === "aborted")
180
+ return status({ status: "aborted" });
181
+ if (external.status === "error")
182
+ return status({ status: "error" });
183
+ return status({ status: "uncertain", reason: "state-save-failed" });
184
+ }
185
+ if (external.locked)
186
+ return status({ status: "locked", reason: "external-title-change" });
187
+ }
188
+ if (args.decision === "keep") {
189
+ const approved = await askOrAbort(context, "stm_title_review", context.sessionID, { decision: "keep" });
190
+ if (!approved.ok)
191
+ return status({ status: approved.status });
192
+ // Serialise the confirmation and resolution with rename's guarded
193
+ // read-check-mutate (never nested): the title may have changed while the
194
+ // ask was pending, and a review resolved, cleared or replaced during the
195
+ // ask must not be resolved by this stale call.
196
+ const outcome = await services.guard.run(context.sessionID, async () => {
197
+ const confirm = await readSession(services.client, context.sessionID, context.abort);
198
+ if (context.abort.aborted)
199
+ return { ok: false, status: "aborted" };
200
+ if (!confirm.ok)
201
+ return { ok: false, status: "error" };
202
+ if (confirm.value.title !== args.expectedTitle) {
203
+ return { ok: false, status: "conflict", reason: "expected-title-mismatch" };
204
+ }
205
+ let resolvedReviewID = null;
206
+ const saved = await services.state.update(context.sessionID, (current) => {
207
+ const base = current ?? meta;
208
+ // Revalidate the caller-provided review ID against the latest
209
+ // state: resolving, clearing or replacing the review while the ask
210
+ // was pending conflicts instead of resolving a review the caller
211
+ // never saw.
212
+ if (args.reviewID !== undefined && (base.review?.id ?? null) !== args.reviewID) {
213
+ return { next: base, result: false };
214
+ }
215
+ resolvedReviewID = base.review?.id ?? null;
216
+ // Resolve against the latest metadata so a lock that landed
217
+ // during the ask is preserved rather than erased.
218
+ return { next: resolveReview(base, hashTitle(confirm.value.title)), result: true };
219
+ });
220
+ if (!saved.ok)
221
+ return { ok: false, status: "uncertain", reason: "state-save-failed" };
222
+ if (saved.value === false)
223
+ return { ok: false, status: "conflict", reason: "stale-review" };
224
+ return { ok: true, reviewID: resolvedReviewID };
225
+ });
226
+ if (!outcome.ok) {
227
+ if (outcome.status === "conflict") {
228
+ return status({ status: "conflict", reason: outcome.reason ?? "expected-title-mismatch" });
229
+ }
230
+ if (outcome.status === "uncertain")
231
+ return status({ status: "uncertain", reason: outcome.reason });
232
+ return status({ status: outcome.status === "aborted" ? "aborted" : "error" });
233
+ }
234
+ return status({ status: "ok", decision: "keep", reviewID: outcome.reviewID });
235
+ }
236
+ if (meta.locked)
237
+ return status({ status: "conflict", reason: "locked" });
238
+ if (newTitle === null)
239
+ return status({ status: "error" });
240
+ const approved = await askOrAbort(context, "stm_title_review", context.sessionID, {
241
+ currentTitle,
242
+ newTitle,
243
+ });
244
+ if (!approved.ok)
245
+ return status({ status: approved.status });
246
+ const outcome = await services.guard.run(context.sessionID, async () => {
247
+ const before = await readSession(services.client, context.sessionID, context.abort);
248
+ if (context.abort.aborted)
249
+ return { outcome: "aborted" };
250
+ if (!before.ok)
251
+ return { outcome: "error" };
252
+ if (before.value.title !== args.expectedTitle)
253
+ return { outcome: "conflict" };
254
+ // A lock mutation (or another guarded transition) may have completed
255
+ // while the permission ask was pending: never write over a lock.
256
+ const latest = await services.state.load(context.sessionID);
257
+ if (context.abort.aborted)
258
+ return { outcome: "aborted" };
259
+ if (!latest.ok)
260
+ return { outcome: "state-unavailable" };
261
+ const effective = latest.value ?? meta;
262
+ if (effective.locked)
263
+ return { outcome: "conflict", reason: "locked" };
264
+ // Revalidate the caller-provided review ID against the latest state: a
265
+ // review resolved or replaced while the ask was pending must conflict
266
+ // instead of writing a title for a review the caller never saw.
267
+ if (args.reviewID !== undefined && (effective.review?.id ?? null) !== args.reviewID) {
268
+ return { outcome: "conflict", reason: "stale-review" };
269
+ }
270
+ const written = await writeTitle(services.client, context.sessionID, newTitle, context.abort);
271
+ // The write may have landed even though the caller aborted: report the
272
+ // partial result and stop. No read-back, no state change, no retry.
273
+ if (context.abort.aborted)
274
+ return { outcome: "uncertain", reason: "aborted-during-write" };
275
+ if (!written.ok)
276
+ return { outcome: "error" };
277
+ const after = await readSession(services.client, context.sessionID, context.abort);
278
+ if (context.abort.aborted)
279
+ return { outcome: "uncertain", reason: "aborted-during-write" };
280
+ if (!after.ok || after.value.title !== newTitle)
281
+ return { outcome: "uncertain" };
282
+ return { outcome: "renamed", title: after.value.title };
283
+ });
284
+ if (outcome.outcome !== "renamed") {
285
+ if (outcome.outcome === "conflict") {
286
+ return status({ status: "conflict", reason: "reason" in outcome ? outcome.reason : "expected-title-mismatch" });
287
+ }
288
+ return "reason" in outcome
289
+ ? status({ status: outcome.outcome, reason: outcome.reason })
290
+ : status({ status: outcome.outcome });
291
+ }
292
+ const saved = await services.state.update(context.sessionID, (current) => ({
293
+ next: resolveReview(current ?? meta, hashTitle(outcome.title)),
294
+ result: true,
295
+ }));
296
+ if (!saved.ok)
297
+ return status({ status: "uncertain", reason: "state-save-failed" });
298
+ return status({ status: "ok", decision: "rename", title: outcome.title });
299
+ },
300
+ });
301
+ const titleLock = tool({
302
+ description: "Lock (pause) or unlock periodic title reviews for the current session.",
303
+ args: { locked: tool.schema.boolean(), expectedTitle: tool.schema.string() },
304
+ async execute(args, context) {
305
+ if (context.abort.aborted)
306
+ return status({ status: "aborted" });
307
+ const caller = await requireRootCaller(services, context);
308
+ if (!caller.ok)
309
+ return status({ status: caller.status });
310
+ const loaded = await services.state.load(context.sessionID);
311
+ if (!loaded.ok)
312
+ return status({ status: "state-unavailable" });
313
+ const approved = await askOrAbort(context, "stm_title_lock", context.sessionID, {});
314
+ if (!approved.ok)
315
+ return status({ status: approved.status });
316
+ // Serialise the whole read-check-mutate sequence with rename's guarded
317
+ // read-check-write. The title must be read inside the guard: a rename that
318
+ // held the guard may have changed it while this call was queued, and the
319
+ // unlock digest must derive from the title actually confirmed here.
320
+ const outcome = await services.guard.run(context.sessionID, async () => {
321
+ if (context.abort.aborted)
322
+ return { ok: false, status: "aborted" };
323
+ const read = await readSession(services.client, context.sessionID, context.abort);
324
+ if (context.abort.aborted)
325
+ return { ok: false, status: "aborted" };
326
+ if (!read.ok)
327
+ return { ok: false, status: "error" };
328
+ if (read.value.title !== args.expectedTitle)
329
+ return { ok: false, status: "conflict" };
330
+ const confirmedTitle = read.value.title;
331
+ const result = await services.state.update(context.sessionID, (current) => {
332
+ const base = current ?? loaded.value ?? initialMeta();
333
+ const next = args.locked ? lockManually(base) : unlock(base, hashTitle(confirmedTitle));
334
+ return { next, result: next.locked };
335
+ });
336
+ if (!result.ok)
337
+ return { ok: false, status: "uncertain" };
338
+ return { ok: true, locked: result.value };
339
+ });
340
+ if (!outcome.ok) {
341
+ if (outcome.status === "aborted")
342
+ return status({ status: "aborted" });
343
+ if (outcome.status === "error")
344
+ return status({ status: "error" });
345
+ if (outcome.status === "conflict") {
346
+ return status({ status: "conflict", reason: "expected-title-mismatch" });
347
+ }
348
+ return status({ status: "uncertain", reason: "state-save-failed" });
349
+ }
350
+ return status({ status: "ok", locked: outcome.locked });
351
+ },
352
+ });
353
+ return { stm_title_read: titleRead, stm_title_review: titleReview, stm_title_lock: titleLock };
354
+ }
355
+ function isBlocked(services, caller, target) {
356
+ return services.blocked.get(caller)?.has(target) ?? false;
357
+ }
358
+ function blockTarget(services, caller, target) {
359
+ const targets = services.blocked.get(caller) ?? new Set();
360
+ targets.add(target);
361
+ services.blocked.set(caller, targets);
362
+ }
363
+ export function createHistoricalTools(services) {
364
+ const tools = {};
365
+ if (services.config.historical.readTitles) {
366
+ tools.stm_history_title_read = tool({
367
+ description: "Opt-in read of one other session's title and root/child metadata. Does not traverse.",
368
+ args: { sessionID: tool.schema.string() },
369
+ async execute(args, context) {
370
+ if (context.abort.aborted)
371
+ return status({ status: "aborted" });
372
+ if (args.sessionID === context.sessionID)
373
+ return status({ status: "invalid", reason: "self-target" });
374
+ const caller = await requireRootCaller(services, context);
375
+ if (!caller.ok)
376
+ return status({ status: caller.status });
377
+ const approved = await askOrAbort(context, "stm_history_title_read", args.sessionID, {});
378
+ if (!approved.ok)
379
+ return status({ status: approved.status });
380
+ const read = await readSession(services.client, args.sessionID, context.abort);
381
+ if (!read.ok)
382
+ return status({ status: "error" });
383
+ const shown = titleForStatus(read.value.title);
384
+ return status({
385
+ status: "ok",
386
+ sessionID: read.value.id,
387
+ title: shown.value,
388
+ titleTruncated: shown.truncated,
389
+ root: read.value.parentID === undefined,
390
+ });
391
+ },
392
+ });
393
+ }
394
+ if (services.config.historical.renameTitles) {
395
+ tools.stm_history_title_rename = tool({
396
+ description: "Opt-in explicit rename of one other root session. Reads back for verification and leaves the target's automatic review locked.",
397
+ args: { sessionID: tool.schema.string(), expectedTitle: tool.schema.string(), title: tool.schema.string() },
398
+ async execute(args, context) {
399
+ if (context.abort.aborted)
400
+ return status({ status: "aborted" });
401
+ if (args.sessionID === context.sessionID)
402
+ return status({ status: "invalid", reason: "self-target" });
403
+ const caller = await requireRootCaller(services, context);
404
+ if (!caller.ok)
405
+ return status({ status: caller.status });
406
+ if (isBlocked(services, context.sessionID, args.sessionID)) {
407
+ return status({ status: "conflict", reason: "sensitive-blocked" });
408
+ }
409
+ const valid = validateTitle(args.title);
410
+ if (!valid.ok)
411
+ return status({ status: "invalid", reason: valid.reason });
412
+ const approved = await askOrAbort(context, "stm_history_title_rename", args.sessionID, {
413
+ expectedTitle: args.expectedTitle,
414
+ newTitle: valid.value,
415
+ });
416
+ if (!approved.ok)
417
+ return status({ status: approved.status });
418
+ // A sensitive review end may have completed while the ask was pending:
419
+ // revalidate before any target read or write.
420
+ if (isBlocked(services, context.sessionID, args.sessionID)) {
421
+ return status({ status: "conflict", reason: "sensitive-blocked" });
422
+ }
423
+ const outcome = await services.guard.run(args.sessionID, async () => {
424
+ const before = await readSession(services.client, args.sessionID, context.abort);
425
+ if (context.abort.aborted)
426
+ return { outcome: "aborted" };
427
+ if (!before.ok)
428
+ return { outcome: "error" };
429
+ // The sensitive stop may have landed while queued on the guard or during
430
+ // the read: never write after it, even though the fast path passed.
431
+ if (isBlocked(services, context.sessionID, args.sessionID)) {
432
+ return { outcome: "conflict", reason: "sensitive-blocked" };
433
+ }
434
+ if (before.value.parentID !== undefined)
435
+ return { outcome: "conflict", reason: "child-target" };
436
+ if (before.value.title !== args.expectedTitle) {
437
+ return { outcome: "conflict", reason: "expected-title-mismatch" };
438
+ }
439
+ const written = await writeTitle(services.client, args.sessionID, valid.value, context.abort);
440
+ // The write may have landed even though the caller aborted: report the
441
+ // partial result and stop. No read-back, no state change, no retry.
442
+ if (context.abort.aborted)
443
+ return { outcome: "uncertain", reason: "aborted-during-write" };
444
+ if (!written.ok)
445
+ return { outcome: "error" };
446
+ const after = await readSession(services.client, args.sessionID, context.abort);
447
+ if (context.abort.aborted)
448
+ return { outcome: "uncertain", reason: "aborted-during-write" };
449
+ if (!after.ok || after.value.title !== valid.value)
450
+ return { outcome: "uncertain" };
451
+ return { outcome: "renamed", title: after.value.title };
452
+ });
453
+ if (outcome.outcome !== "renamed") {
454
+ return "reason" in outcome
455
+ ? status({ status: outcome.outcome, reason: outcome.reason })
456
+ : status({ status: outcome.outcome });
457
+ }
458
+ // Lock the target's automatic review against the latest persisted state so a
459
+ // concurrent metadata change is preserved rather than overwritten.
460
+ const saved = await services.state.update(args.sessionID, (current) => {
461
+ const base = current ?? initialMeta();
462
+ return {
463
+ next: { ...base, locked: true, lockReason: "historical", titleDigest: hashTitle(outcome.title) },
464
+ result: true,
465
+ };
466
+ });
467
+ if (!saved.ok)
468
+ return status({ status: "uncertain", reason: "state-save-failed" });
469
+ return status({ status: "ok", sessionID: args.sessionID, title: outcome.title });
470
+ },
471
+ });
472
+ }
473
+ if (services.config.historical.readTranscript) {
474
+ tools.stm_history_transcript = tool({
475
+ description: "Opt-in bounded historical text chunk queued for the next outgoing context. Returns counts and cursors only, never text.",
476
+ args: { sessionID: tool.schema.string(), cursor: tool.schema.optional(tool.schema.string()) },
477
+ async execute(args, context) {
478
+ if (context.abort.aborted)
479
+ return status({ status: "aborted" });
480
+ if (args.sessionID === context.sessionID)
481
+ return status({ status: "invalid", reason: "self-target" });
482
+ const caller = await requireRootCaller(services, context);
483
+ if (!caller.ok)
484
+ return status({ status: caller.status });
485
+ if (isBlocked(services, context.sessionID, args.sessionID)) {
486
+ return status({ status: "conflict", reason: "sensitive-blocked" });
487
+ }
488
+ const approved = await askOrAbort(context, "stm_history_transcript", args.sessionID, {});
489
+ if (!approved.ok)
490
+ return status({ status: approved.status });
491
+ // A sensitive review end may have completed while the ask was pending:
492
+ // revalidate before reading the target or fetching any page.
493
+ if (isBlocked(services, context.sessionID, args.sessionID)) {
494
+ return status({ status: "conflict", reason: "sensitive-blocked" });
495
+ }
496
+ const target = await readSession(services.client, args.sessionID, context.abort);
497
+ if (!target.ok)
498
+ return status({ status: "error" });
499
+ // The sensitive stop may have landed during the target read.
500
+ if (isBlocked(services, context.sessionID, args.sessionID)) {
501
+ return status({ status: "conflict", reason: "sensitive-blocked" });
502
+ }
503
+ if (target.value.parentID !== undefined)
504
+ return status({ status: "conflict", reason: "child-target" });
505
+ const begun = services.queue.begin({
506
+ callerSessionID: context.sessionID,
507
+ targetSessionID: args.sessionID,
508
+ requestMessageID: context.messageID,
509
+ signal: context.abort,
510
+ ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
511
+ }, services.now());
512
+ if (!begun.ok) {
513
+ return begun.reason === "queue: aborted"
514
+ ? status({ status: "aborted" })
515
+ : status({ status: "conflict", reason: begun.reason });
516
+ }
517
+ const fetched = await page(services.client, args.sessionID, begun.fetchCursor, context.abort);
518
+ if (context.abort.aborted) {
519
+ // The abort listener has already dropped any pending chunk; release again
520
+ // is idempotent. Do not queue text from an aborted call. The token scopes
521
+ // the release to this fetch generation.
522
+ services.queue.release(context.sessionID, begun.token);
523
+ return status({ status: "aborted" });
524
+ }
525
+ if (!fetched.ok)
526
+ return status({ status: "error" });
527
+ if (context.abort.aborted) {
528
+ services.queue.release(context.sessionID, begun.token);
529
+ return status({ status: "aborted" });
530
+ }
531
+ // A sensitive review end may have completed during the fetch: discard the
532
+ // fetched text instead of queueing it, scoped to this fetch generation.
533
+ if (isBlocked(services, context.sessionID, args.sessionID)) {
534
+ services.queue.release(context.sessionID, begun.token);
535
+ return status({ status: "conflict", reason: "sensitive-blocked" });
536
+ }
537
+ const supplied = services.queue.supply(context.sessionID, { entries: extractVisibleText(fetched.value.items), nextCursor: fetched.value.nextCursor }, services.now(), begun.token);
538
+ if (!supplied.ok) {
539
+ return supplied.reason === "queue: aborted"
540
+ ? status({ status: "aborted" })
541
+ : status({ status: "conflict", reason: supplied.reason });
542
+ }
543
+ if (context.abort.aborted) {
544
+ // An abort that landed before the handoff is a real in-flight
545
+ // cancellation: the listener (or this release) discards the chunk.
546
+ services.queue.release(context.sessionID, begun.token);
547
+ return status({ status: "aborted" });
548
+ }
549
+ if (supplied.ack.continuation) {
550
+ // Commit the queued chunk before returning: OpenCode 1.18.35 aborts the
551
+ // per-tool signal after every successful return, and the handoff detaches
552
+ // that listener so the next transform can still take the chunk.
553
+ const handed = services.queue.handoff(context.sessionID, begun.token);
554
+ if (!handed.ok)
555
+ return status({ status: "conflict", reason: handed.reason });
556
+ }
557
+ return status({
558
+ status: "ok",
559
+ sessionID: args.sessionID,
560
+ messages: supplied.ack.messages,
561
+ bytes: supplied.ack.bytes,
562
+ cursor: supplied.ack.cursor,
563
+ more: supplied.ack.more,
564
+ complete: supplied.ack.complete,
565
+ continuation: supplied.ack.continuation,
566
+ });
567
+ },
568
+ });
569
+ tools.stm_history_review_end = tool({
570
+ description: "Discard queued transcript content and end this caller's review of a target. A sensitive outcome blocks further transcript reads and renames of that target.",
571
+ args: { sessionID: tool.schema.string(), outcome: tool.schema.enum(["reviewed", "sensitive", "cancelled"]) },
572
+ async execute(args, context) {
573
+ if (context.abort.aborted)
574
+ return status({ status: "aborted" });
575
+ if (args.sessionID === context.sessionID)
576
+ return status({ status: "invalid", reason: "self-target" });
577
+ const caller = await requireRootCaller(services, context);
578
+ if (!caller.ok)
579
+ return status({ status: caller.status });
580
+ const approved = await askOrAbort(context, "stm_history_review_end", args.sessionID, {
581
+ outcome: args.outcome,
582
+ });
583
+ if (!approved.ok)
584
+ return status({ status: approved.status });
585
+ const ended = services.queue.endReview(context.sessionID, args.sessionID);
586
+ if (!ended.ok)
587
+ return status({ status: "conflict", reason: ended.reason });
588
+ if (args.outcome === "sensitive")
589
+ blockTarget(services, context.sessionID, args.sessionID);
590
+ return status({ status: "ok", outcome: args.outcome });
591
+ },
592
+ });
593
+ }
594
+ return tools;
595
+ }
596
+ //# sourceMappingURL=tools.js.map