codexteer 0.19.0 → 0.20.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.
package/src/cursor.mjs ADDED
@@ -0,0 +1,370 @@
1
+ // The read cursor format. No other module builds or reads cursor bytes.
2
+ //
3
+ // token = "c1" base64url(check[8] body). The check is SHA-256 over the target
4
+ // binding and the body. It is not a MAC: it binds the token to the thread, host
5
+ // and transport without storing them, and rejects copy errors. Sends stay safe
6
+ // because every --based-on and --since is re-validated against the live server.
7
+ //
8
+ // A v2 body commits to the turn list instead of listing it, so the token does
9
+ // not grow with the thread. parseCursor needs no server and returns a claim.
10
+ // resolveTurns checks the claim against the live turn list and rebuilds the
11
+ // in-memory cursor that readPaged and verifyPagedFreshness use.
12
+ import { createHash } from "node:crypto";
13
+ import { hostFields, targetHost, remoteTransport } from "./target.mjs";
14
+
15
+ const PREFIX = "c1", MAX_TOKEN = 1024 * 1024, MAX_POSITION = 16384;
16
+ const RUNNING = ["inProgress", "running", "pending"];
17
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
18
+ const HASH = /^[a-f0-9]{64}$/;
19
+
20
+ /** @typedef {{ kind: "none" } | { kind: "turn", index: number } | { kind: "inline", id: string }} TurnRef */
21
+ /**
22
+ * What a v2 token proves about the turn list without listing it. `running` holds
23
+ * the status each running turn had when the token was made.
24
+ * @typedef {{ count: number, commitment: string | null, running: Map<number, string> }} TurnCommitment
25
+ */
26
+ /**
27
+ * Offline view of a v2 cursor. It has no `turns`, so code that skips resolveTurns
28
+ * fails on first use instead of running a weaker check.
29
+ * @typedef {{ v: 2, turn_commitment: TurnCommitment, active_turn_id: string | null, state: string, pending: boolean,
30
+ * head: { cursor: string | null, id: string | null, turn: TurnRef, hash: string | null }, last_public: string | null,
31
+ * groups: { cursor: string | null, turn: TurnRef, count: number,
32
+ * items: { index: number, id: string, turn: TurnRef, hash: string, mode: "running" | "tail" }[] }[] }} PagedClaim
33
+ */
34
+ /**
35
+ * @typedef {{ v: 1, count: number, open: { index: number, hash: string }[], prefix: string, state: string,
36
+ * active_turn_id: string | null, latest_turn_id?: string | null, user_revision?: string }} SnapshotCursor
37
+ */
38
+ /**
39
+ * @typedef {{ v: 2, thread_id: string, turns: { id: string, status: string }[],
40
+ * head: { cursor: string | null, id: string | null, turn_id: string | null, hash: string | null },
41
+ * groups: { cursor: string | null, turn_id: string | null, count: number,
42
+ * items: { index: number, id: string, turn_id: string, hash: string, mode: "running" | "tail" }[] }[],
43
+ * last_public: string | null, state: string, active_turn_id: string | null, pending: boolean }} PagedCursor
44
+ */
45
+
46
+ export function invalidCursor() { return Object.assign(new Error("Cursor is invalid or history changed. Read again without --since."), { code: "STALE_CURSOR" }); }
47
+
48
+ /**
49
+ * @param {PagedCursor | SnapshotCursor} cursor
50
+ * @param {string} threadId
51
+ */
52
+ export function encodeCursor(cursor, threadId) {
53
+ const w = new Writer();
54
+ if (cursor.v === 1) writeSnapshotBody(w, cursor); else writePagedBody(w, claimOf(cursor), threadId);
55
+ const body = w.done(), token = PREFIX + Buffer.concat([check(threadId, body), body]).toString("base64url");
56
+ // v1 is the --full-history fallback and has always been uncapped, so only the fast cursor is refused here.
57
+ if (cursor.v === 2 && token.length > MAX_TOKEN) throw Object.assign(new Error("Too many in-progress items for a fast cursor. Use --full-history."), { code: "CURSOR_TOO_LARGE" });
58
+ return token;
59
+ }
60
+
61
+ /**
62
+ * Parse without the server. Accepts "c1" tokens and the earlier base64url JSON tokens.
63
+ * @param {unknown} token
64
+ * @param {string} threadId
65
+ * @returns {PagedClaim | SnapshotCursor}
66
+ */
67
+ export function parseCursor(token, threadId) {
68
+ try {
69
+ if (typeof token !== "string" || token.length > MAX_TOKEN) throw invalidCursor();
70
+ if (!token.startsWith(PREFIX)) return parseLegacy(token, threadId);
71
+ const text = token.slice(PREFIX.length), bytes = Buffer.from(text, "base64url");
72
+ // Node decodes base64url leniently, so only the canonical spelling of the bytes is accepted.
73
+ if (bytes.length < 9 || bytes.toString("base64url") !== text) throw invalidCursor();
74
+ const body = bytes.subarray(8);
75
+ if (!check(threadId, body).equals(bytes.subarray(0, 8))) throw invalidCursor();
76
+ const r = new Reader(body), version = r.byte();
77
+ if (version !== 1 && version !== 2) throw invalidCursor();
78
+ const claim = version === 1 ? readSnapshotBody(r) : readPagedBody(r, threadId);
79
+ r.end();
80
+ return claim;
81
+ } catch { throw invalidCursor(); }
82
+ }
83
+
84
+ /**
85
+ * Check a v2 claim against the live turn list and rebuild the cursor. Accepts a live
86
+ * list exactly when it extends the committed one with the same ids in order and every
87
+ * turn that was final keeps its status.
88
+ * @param {PagedClaim} claim
89
+ * @param {{ id: string, status: string }[]} liveTurns from turnMetadata, free of duplicates
90
+ * @param {string} threadId
91
+ * @returns {PagedCursor}
92
+ */
93
+ export function resolveTurns(claim, liveTurns, threadId) {
94
+ const { count, commitment: expected, running } = claim.turn_commitment;
95
+ if (liveTurns.length < count) throw invalidCursor();
96
+ const prefix = liveTurns.slice(0, count);
97
+ if (count && commitment(prefix, running) !== expected) throw invalidCursor();
98
+ // A running turn keeps its observed status, so the next read reports its change.
99
+ const turns = prefix.map((t, i) => ({ id: t.id, status: running.get(i) ?? t.status }));
100
+ const turnId = ref => ref.kind === "none" ? null : ref.kind === "inline" ? ref.id : turns[ref.index].id;
101
+ return { v: 2, thread_id: threadId, turns,
102
+ head: { cursor: claim.head.cursor, id: claim.head.id, turn_id: turnId(claim.head.turn), hash: claim.head.hash },
103
+ groups: claim.groups.map(g => ({ cursor: g.cursor, turn_id: turnId(g.turn), count: g.count,
104
+ items: g.items.map(i => ({ index: i.index, id: i.id, turn_id: turnId(i.turn), hash: i.hash, mode: i.mode })) })),
105
+ last_public: claim.last_public, state: claim.state, active_turn_id: claim.active_turn_id, pending: claim.pending };
106
+ }
107
+
108
+ /**
109
+ * The output view of a stored record. Its based_on token becomes based_on_sha256,
110
+ * the hex SHA-256 of the token. Stored records keep the full token.
111
+ * @template {object} T
112
+ * @param {T} record
113
+ */
114
+ export function withCursorReference(record) {
115
+ if (!record || !Object.hasOwn(record, "based_on")) return record;
116
+ return Object.fromEntries(Object.entries(record).map(([key, value]) => key !== "based_on" ? [key, value]
117
+ : ["based_on_sha256", typeof value === "string" ? createHash("sha256").update(value).digest("hex") : null]));
118
+ }
119
+
120
+ const check = (threadId, body) => createHash("sha256").update(JSON.stringify(["codexteer-cursor", threadId, hostFields()])).update(body).digest().subarray(0, 8);
121
+ const commitment = (turns, running) => createHash("sha256").update(JSON.stringify(turns.map((t, i) => [t.id, running.has(i) ? null : t.status]))).digest("hex");
122
+
123
+ /** @param {PagedCursor} cursor @returns {PagedClaim} */
124
+ function claimOf(cursor) {
125
+ const index = new Map(cursor.turns.map((t, i) => [t.id, i]));
126
+ const running = new Map(cursor.turns.flatMap((t, i) => RUNNING.includes(t.status) ? [[i, t.status]] : []));
127
+ const turn = id => id === null ? { kind: "none" } : index.has(id) ? { kind: "turn", index: index.get(id) } : { kind: "inline", id };
128
+ const count = cursor.turns.length;
129
+ return { v: 2, turn_commitment: { count, commitment: count ? commitment(cursor.turns, running) : null, running },
130
+ active_turn_id: cursor.active_turn_id, state: cursor.state, pending: cursor.pending,
131
+ head: { cursor: cursor.head.cursor, id: cursor.head.id, turn: turn(cursor.head.turn_id), hash: cursor.head.hash },
132
+ last_public: cursor.last_public,
133
+ groups: cursor.groups.map(g => ({ cursor: g.cursor, turn: turn(g.turn_id), count: g.count,
134
+ items: g.items.map(i => ({ index: i.index, id: i.id, turn: turn(i.turn_id), hash: i.hash, mode: i.mode })) })) };
135
+ }
136
+
137
+ // v2 body: 0x02 flags count commitment? running active? state ids head last_public? groups.
138
+ // Flags: 1 pending, 2 active turn, 4 head id, 8 head hash, 16 last public.
139
+ function writePagedBody(w, claim, threadId) {
140
+ const { count, commitment, running } = claim.turn_commitment, { head } = claim;
141
+ const ids = [...new Set([head.id, claim.last_public, ...claim.groups.flatMap(g => g.items.map(i => i.id))].filter(id => id !== null))];
142
+ const idIndex = new Map(ids.map((id, i) => [id, i]));
143
+ w.byte(2);
144
+ w.byte((claim.pending ? 1 : 0) | (claim.active_turn_id !== null ? 2 : 0) | (head.id !== null ? 4 : 0) | (head.hash !== null ? 8 : 0) | (claim.last_public !== null ? 16 : 0));
145
+ w.uint(count);
146
+ if (count) w.hash(commitment);
147
+ w.uint(running.size);
148
+ let previous = -1;
149
+ for (const [index, status] of running) { w.uint(index - previous - 1); w.byte(RUNNING.indexOf(status)); previous = index; }
150
+ if (claim.active_turn_id !== null) writeId(w, claim.active_turn_id);
151
+ w.hash(claim.state);
152
+ w.uint(ids.length);
153
+ for (const id of ids) writeId(w, id);
154
+ writePosition(w, head.cursor, threadId);
155
+ if (head.id !== null) w.uint(idIndex.get(head.id));
156
+ writeTurn(w, head.turn);
157
+ if (head.hash !== null) w.hash(head.hash);
158
+ if (claim.last_public !== null) w.uint(idIndex.get(claim.last_public));
159
+ w.uint(claim.groups.length);
160
+ for (const group of claim.groups) {
161
+ writePosition(w, group.cursor, threadId); writeTurn(w, group.turn); w.uint(group.count); w.uint(group.items.length);
162
+ for (const item of group.items) {
163
+ // Most tracked items are the head itself, so their hash is one bit.
164
+ const same = item.hash === head.hash;
165
+ w.uint(item.index * 4 + (item.mode === "tail" ? 2 : 0) + (same ? 1 : 0)); w.uint(idIndex.get(item.id)); writeTurn(w, item.turn);
166
+ if (!same) w.hash(item.hash);
167
+ }
168
+ }
169
+ }
170
+
171
+ function readPagedBody(r, threadId) {
172
+ const flags = r.byte();
173
+ if (flags > 31) throw invalidCursor();
174
+ const count = r.uint(), commitment = count ? r.hash() : null, running = new Map();
175
+ for (let n = r.count(), index = -1; n > 0; n--) {
176
+ index += r.uint() + 1;
177
+ const status = RUNNING[r.byte()];
178
+ if (index >= count || !status) throw invalidCursor();
179
+ running.set(index, status);
180
+ }
181
+ const active_turn_id = flags & 2 ? readId(r) : null, state = r.hash(), ids = [];
182
+ for (let n = r.count(); n > 0; n--) ids.push(readId(r));
183
+ const id = () => { const k = r.uint(); if (k >= ids.length) throw invalidCursor(); return ids[k]; };
184
+ const head = { cursor: readPosition(r, threadId), id: flags & 4 ? id() : null, turn: readTurn(r, count), hash: flags & 8 ? r.hash() : null };
185
+ if (head.id !== null && head.cursor === null) throw invalidCursor();
186
+ const last_public = flags & 16 ? id() : null, groups = [];
187
+ for (let n = r.count(); n > 0; n--) {
188
+ const group = { cursor: readPosition(r, threadId), turn: readTurn(r, count), count: r.uint(), items: [] };
189
+ if (group.count < 1 || group.count > 100) throw invalidCursor();
190
+ for (let m = r.count(); m > 0; m--) {
191
+ const packed = r.uint(), index = Math.floor(packed / 4), same = packed % 2 === 1;
192
+ const item = { index, id: id(), turn: readTurn(r, count), hash: same ? head.hash : r.hash(), mode: packed % 4 >= 2 ? "tail" : "running" };
193
+ if (index >= group.count || item.hash === null || item.turn.kind === "none") throw invalidCursor();
194
+ group.items.push(item);
195
+ }
196
+ groups.push(group);
197
+ }
198
+ return { v: 2, turn_commitment: { count, commitment, running }, active_turn_id, state, pending: (flags & 1) === 1, head, last_public, groups };
199
+ }
200
+
201
+ // v1 body: 0x01 flags count open prefix state user_revision active? latest?
202
+ // Flags: 1 active turn, 2 latest turn. readSnapshot always sets every field.
203
+ function writeSnapshotBody(w, cursor) {
204
+ w.byte(1);
205
+ w.byte((cursor.active_turn_id !== null ? 1 : 0) | (cursor.latest_turn_id !== null ? 2 : 0));
206
+ w.uint(cursor.count); w.uint(cursor.open.length);
207
+ let previous = -1;
208
+ for (const { index, hash } of cursor.open) { w.uint(index - previous - 1); w.hash(hash); previous = index; }
209
+ w.hash(cursor.prefix); w.hash(cursor.state); w.hash(cursor.user_revision);
210
+ if (cursor.active_turn_id !== null) writeId(w, cursor.active_turn_id);
211
+ if (cursor.latest_turn_id !== null) writeId(w, cursor.latest_turn_id);
212
+ }
213
+
214
+ function readSnapshotBody(r) {
215
+ const flags = r.byte();
216
+ if (flags > 3) throw invalidCursor();
217
+ const count = r.uint(), open = [];
218
+ for (let n = r.count(), index = -1; n > 0; n--) {
219
+ index += r.uint() + 1;
220
+ if (index >= count) throw invalidCursor();
221
+ open.push({ index, hash: r.hash() });
222
+ }
223
+ const prefix = r.hash(), state = r.hash(), user_revision = r.hash();
224
+ const active_turn_id = flags & 1 ? readId(r) : null, latest_turn_id = flags & 2 ? readId(r) : null;
225
+ return { v: 1, count, open, prefix, state, active_turn_id, latest_turn_id, user_revision };
226
+ }
227
+
228
+ // An id is a tag and a payload: 0 a UUID, 1 a short prefix and a UUID, 2 a prefix and lowercase hex, 3 text.
229
+ const ID_PARTS = /^([a-z]{1,16}[_-])?(.*)$/s;
230
+ const uuid = bytes => bytes.toString("hex").replace(/^(.{8})(.{4})(.{4})(.{4})/, "$1-$2-$3-$4-");
231
+
232
+ function writeId(w, id) {
233
+ if (UUID.test(id)) { w.byte(0); w.bytes(Buffer.from(id.replaceAll("-", ""), "hex")); return; }
234
+ const [, prefix = "", body] = ID_PARTS.exec(id);
235
+ if (prefix && UUID.test(body)) {
236
+ w.byte(1); w.text(prefix); w.bytes(Buffer.from(body.replaceAll("-", ""), "hex"));
237
+ } else if (/^(?:[0-9a-f]{2})+$/.test(body)) {
238
+ w.byte(2); w.text(prefix); w.uint(body.length / 2); w.bytes(Buffer.from(body, "hex"));
239
+ } else { w.byte(3); w.text(id); }
240
+ }
241
+
242
+ function readId(r) {
243
+ const tag = r.byte();
244
+ if (tag === 0) return uuid(r.bytes(16));
245
+ if (tag === 1) return r.text() + uuid(r.bytes(16));
246
+ if (tag === 2) return r.text() + r.bytes(r.uint()).toString("hex");
247
+ if (tag === 3) return r.text();
248
+ throw invalidCursor();
249
+ }
250
+
251
+ // App Server positions are kept byte for byte. The known form is stored as an
252
+ // ordinal only when rendering it back reproduces the server's exact string, so
253
+ // codexteer never synthesizes a position and a format change only costs size.
254
+ // Tags: 0 null, 1 known form with includeAnchor true, 2 with false, 3 the exact text.
255
+ const position = (threadId, rolloutOrdinal, includeAnchor) => JSON.stringify({ requestedThreadId: threadId, rolloutOrdinal, includeAnchor, scope: { kind: "itemsByCreatedAtOrdinal" } });
256
+
257
+ function writePosition(w, value, threadId) {
258
+ if (value === null) return w.byte(0);
259
+ let known = null;
260
+ try { known = JSON.parse(value); } catch { /* Not JSON, so it is stored as text. */ }
261
+ if (Number.isSafeInteger(known?.rolloutOrdinal) && known.rolloutOrdinal >= 0 && typeof known.includeAnchor === "boolean"
262
+ && position(threadId, known.rolloutOrdinal, known.includeAnchor) === value) {
263
+ w.byte(known.includeAnchor ? 1 : 2); w.uint(known.rolloutOrdinal);
264
+ } else { w.byte(3); w.text(value); }
265
+ }
266
+
267
+ function readPosition(r, threadId) {
268
+ const tag = r.byte();
269
+ if (tag === 0) return null;
270
+ if (tag === 1 || tag === 2) return position(threadId, r.uint(), tag === 1);
271
+ if (tag !== 3) throw invalidCursor();
272
+ const value = r.text();
273
+ if (value.length >= MAX_POSITION) throw invalidCursor();
274
+ return value;
275
+ }
276
+
277
+ // A turn reference: 0 none, 1 an inline id, k + 2 the committed turn k.
278
+ function writeTurn(w, ref) {
279
+ if (ref.kind === "none") w.uint(0);
280
+ else if (ref.kind === "inline") { w.uint(1); writeId(w, ref.id); }
281
+ else w.uint(ref.index + 2);
282
+ }
283
+
284
+ function readTurn(r, count) {
285
+ const n = r.uint();
286
+ if (n === 0) return { kind: "none" };
287
+ if (n === 1) return { kind: "inline", id: readId(r) };
288
+ if (n - 2 >= count) throw invalidCursor();
289
+ return { kind: "turn", index: n - 2 };
290
+ }
291
+
292
+ // The earlier format, base64url(JSON). These are the 0.19 decoder's checks, plus
293
+ // lowercase-hex v1 hashes, which every earlier encoder wrote and the v1 encoder needs.
294
+ function parseLegacy(token, threadId) {
295
+ const c = JSON.parse(Buffer.from(token, "base64url").toString());
296
+ if ((c.host_id ?? "local") !== targetHost()) throw invalidCursor();
297
+ if (targetHost() !== "local" && (c.remote_transport ?? "native") !== remoteTransport()) throw invalidCursor();
298
+ if (c?.v === 2) {
299
+ const hash = x => x === null || (typeof x === "string" && HASH.test(x));
300
+ const pos = x => x === null || (typeof x === "string" && x.length < MAX_POSITION);
301
+ if (c.thread_id !== threadId || typeof c.state !== "string" || !Array.isArray(c.turns) || !Array.isArray(c.groups)
302
+ || !c.turns.every(t => typeof t.id === "string" && typeof t.status === "string")
303
+ || new Set(c.turns.map(t => t.id)).size !== c.turns.length
304
+ || !c.head || !pos(c.head.cursor) || !hash(c.head.hash) || !(c.head.id === null || typeof c.head.id === "string")
305
+ || !(c.head.turn_id === null || typeof c.head.turn_id === "string") || (c.head.id !== null && c.head.cursor === null)
306
+ || !(c.last_public === null || typeof c.last_public === "string")
307
+ || !(c.active_turn_id === null || typeof c.active_turn_id === "string") || typeof c.pending !== "boolean"
308
+ || !c.groups.every(g => pos(g.cursor) && (g.turn_id === null || typeof g.turn_id === "string") && Number.isInteger(g.count) && g.count > 0 && g.count <= 100
309
+ && Array.isArray(g.items) && g.items.every(i => Number.isInteger(i.index) && i.index >= 0 && i.index < g.count && typeof i.id === "string" && typeof i.turn_id === "string" && hash(i.hash) && ["running", "tail"].includes(i.mode)))) throw invalidCursor();
310
+ return claimOf(c);
311
+ }
312
+ if (c.v !== 1 || c.thread_id !== threadId || !Number.isSafeInteger(c.count) || c.count < 0 || !Array.isArray(c.open)
313
+ || !c.open.every(x => Number.isSafeInteger(x.index) && x.index >= 0 && x.index < c.count && typeof x.hash === "string" && HASH.test(x.hash))
314
+ || new Set(c.open.map(x => x.index)).size !== c.open.length || typeof c.prefix !== "string"
315
+ || !(c.latest_turn_id === undefined || c.latest_turn_id === null || (typeof c.latest_turn_id === "string" && c.latest_turn_id))) throw invalidCursor();
316
+ return c;
317
+ }
318
+
319
+ class Writer {
320
+ #parts = [];
321
+ byte(value) { this.#parts.push(Buffer.of(value)); }
322
+ // Every integer in a cursor is a count, an index or an ordinal. Anything else is a bug that must not be written.
323
+ uint(value) {
324
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("A cursor integer must be a non-negative safe integer.");
325
+ const out = [];
326
+ for (; value >= 128; value = Math.floor(value / 128)) out.push(value % 128 | 128);
327
+ out.push(value);
328
+ this.#parts.push(Buffer.from(out));
329
+ }
330
+ bytes(value) { this.#parts.push(value); }
331
+ hash(hex) {
332
+ if (!HASH.test(hex)) throw new Error("A cursor hash must be 64 lowercase hex characters.");
333
+ this.#parts.push(Buffer.from(hex, "hex"));
334
+ }
335
+ // UTF-8 cannot carry a lone surrogate, so such text is stored as its JSON spelling, marked by the low bit.
336
+ text(value) {
337
+ const json = !value.isWellFormed(), bytes = Buffer.from(json ? JSON.stringify(value) : value);
338
+ this.uint(bytes.length * 2 + (json ? 1 : 0)); this.bytes(bytes);
339
+ }
340
+ done() { return Buffer.concat(this.#parts); }
341
+ }
342
+
343
+ // Every read is bounded by the buffer: a length or count larger than the bytes left fails before any allocation.
344
+ class Reader {
345
+ #buffer; #at = 0;
346
+ constructor(buffer) { this.#buffer = buffer; }
347
+ get #left() { return this.#buffer.length - this.#at; }
348
+ byte() { if (!this.#left) throw invalidCursor(); return this.#buffer[this.#at++]; }
349
+ uint() {
350
+ let value = 0;
351
+ for (let k = 0, scale = 1; k < 8; k++, scale *= 128) {
352
+ const x = this.byte();
353
+ value += (x & 127) * scale;
354
+ if (x < 128) { if (!Number.isSafeInteger(value)) throw invalidCursor(); return value; }
355
+ }
356
+ throw invalidCursor();
357
+ }
358
+ // Each counted element takes at least one byte.
359
+ count() { const n = this.uint(); if (n > this.#left) throw invalidCursor(); return n; }
360
+ bytes(n) { if (n > this.#left) throw invalidCursor(); return this.#buffer.subarray(this.#at, this.#at += n); }
361
+ hash() { return this.bytes(32).toString("hex"); }
362
+ text() {
363
+ const n = this.uint(), raw = this.bytes(Math.floor(n / 2)).toString();
364
+ if (n % 2 === 0) return raw;
365
+ const value = JSON.parse(raw);
366
+ if (typeof value !== "string") throw invalidCursor();
367
+ return value;
368
+ }
369
+ end() { if (this.#left) throw invalidCursor(); }
370
+ }
package/src/directive.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { collectEvidence, verifyEvidence } from "./evidence.mjs";
2
- import { decodeCursor, threadSnapshot, fetchThread } from "./observe.mjs";
2
+ import { threadSnapshot, fetchThread } from "./observe.mjs";
3
+ import { parseCursor } from "./cursor.mjs";
3
4
  import { getCheckpoint, verifyCheckpoint } from "./checkpoint.mjs";
4
5
  import { verifyPagedFreshness } from "./paged-read.mjs";
5
6
  import { assertFindingSend, getFinding } from "./findings.mjs";
@@ -15,7 +16,7 @@ export async function prepareDirective(threadId, body, options, id, storage = {}
15
16
  if (expiresAt && (!/(Z|[+-]\d{2}:\d{2})$/.test(expiresAt) || !Number.isFinite(Date.parse(expiresAt)) || Date.parse(expiresAt) <= Date.now())) throw Object.assign(new Error("--expires-at must be a future ISO timestamp with timezone."), { code: "OPTIONS_INVALID" });
16
17
  if (kind && !Object.hasOwn(kindLabels, kind)) throw Object.assign(new Error("--kind must be decision, review, hypothesis, or suggestion."), { code: "OPTIONS_INVALID" });
17
18
  if (source != null && (!source.trim() || source.length > 80 || /[\r\n\x00-\x1f]/.test(source))) throw Object.assign(new Error("--source must be a non-empty single line (up to 80 characters)."), { code: "OPTIONS_INVALID" });
18
- const observed = basedOn ? decodeCursor(basedOn, threadId) : null;
19
+ const observed = basedOn ? parseCursor(basedOn, threadId) : null;
19
20
  const refs = await collectEvidence(evidence);
20
21
  const metadata = { source: source ?? "external", kind: kind ?? "review", evidence: refs, based_on: basedOn ?? null, supersedes: supersedes ?? null, retracts: retracts ?? null, expires_at: expiresAt ?? null, checkpoint_id: checkpoint ?? null,
21
22
  ...(concern ? { finding_id: concern.id, finding_revision: concern.revision } : {}), ...(supervisor ? { supervisor_id: supervisor } : {}) };
@@ -1,6 +1,6 @@
1
1
  import { constants } from "node:fs";
2
2
  import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm } from "node:fs/promises";
3
- import { createHash } from "node:crypto";
3
+ import { createHash, randomBytes } from "node:crypto";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import path from "node:path";
6
6
  import { setTimeout as delay } from "node:timers/promises";
@@ -77,6 +77,25 @@ async function readSafe(file, privateMode) {
77
77
  } finally { await handle.close(); }
78
78
  }
79
79
 
80
+ const present = file => lstat(file).then(() => true, e => { if (e.code !== "ENOENT") throw e; return false; });
81
+
82
+ // An abandoned lock is never stolen. A concurrent caller can reuse the
83
+ // completed placement after its owner releases this lock.
84
+ async function exclusively(lock, operation) {
85
+ let lockInfo;
86
+ for (let attempt = 0; attempt < 100; attempt++) {
87
+ try { await mkdir(lock, { mode: 0o700 }); lockInfo = await ownedDirectory(lock); break; }
88
+ catch (error) {
89
+ if (error.code !== "EEXIST") throw error;
90
+ await ownedDirectory(lock).catch(e => { if (e.code !== "ENOENT") throw e; });
91
+ await delay(50);
92
+ }
93
+ }
94
+ if (!lockInfo) throw failure(`Placement is busy or an earlier placement was interrupted. Inspect ${lock} before retrying.`, "DEPLOYMENT_BUSY");
95
+ try { return await operation(); }
96
+ finally { if (sameFile(lockInfo, await lstat(lock).catch(() => ({})))) await rm(lock, { recursive: true }); }
97
+ }
98
+
80
99
  async function writeExclusive(file, bytes, mode) {
81
100
  const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);
82
101
  try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
@@ -155,38 +174,44 @@ export async function prepareDeployment(home, { sourceRoot = PACKAGE_ROOT } = {}
155
174
  const canonicalHome = await prepareCodexHome(home);
156
175
  const stateRoot = path.join(canonicalHome, "codex-steer"), root = path.join(stateRoot, "runtimes");
157
176
  await ownedDirectory(stateRoot, true); await ownedDirectory(root, true);
158
- const target = path.join(root, distribution.id), lock = path.join(root, `.install-${distribution.id}`);
159
- let lockInfo;
160
- // An abandoned lock is never stolen. A concurrent caller can reuse the
161
- // completed distribution after its installer releases this lock.
162
- for (let attempt = 0; attempt < 100; attempt++) {
163
- try { await mkdir(lock, { mode: 0o700 }); lockInfo = await ownedDirectory(lock); break; }
164
- catch (error) {
165
- if (error.code !== "EEXIST") throw error;
166
- await ownedDirectory(lock).catch(e => { if (e.code !== "ENOENT") throw e; });
167
- await delay(50);
177
+ const target = path.join(root, distribution.id);
178
+ return exclusively(path.join(root, `.install-${distribution.id}`), async () => {
179
+ let stage, stageInfo;
180
+ try {
181
+ if (await present(target)) return { ...await verifyDeployment(target, distribution), codex_home: canonicalHome, reused: true };
182
+ stage = await mkdtemp(path.join(root, ".stage-")); stageInfo = await ownedDirectory(stage);
183
+ for (const file of distribution.manifest.files) {
184
+ const parts = file.path.split("/"); parts.pop(); let parent = stage;
185
+ for (const part of parts) { parent = path.join(parent, part); await ownedDirectory(parent, true); }
186
+ await writeExclusive(path.join(stage, file.path), distribution.contents.get(file.path), file.mode);
187
+ }
188
+ await writeExclusive(path.join(stage, RECORD), JSON.stringify({ manifest: distribution.manifest, sha256: distribution.sha256 }) + "\n", 0o600);
189
+ await verifyDeployment(stage, distribution);
190
+ // All cooperating installers hold the exclusive lock, and the parent is
191
+ // private. Refuse even an empty existing directory rather than replacing it.
192
+ if (await present(target)) throw failure("Deployment destination appeared during placement.");
193
+ await rename(stage, target); stage = null;
194
+ return { ...await verifyDeployment(target, distribution), codex_home: canonicalHome, reused: false };
195
+ } finally {
196
+ if (stage && sameFile(stageInfo, await lstat(stage).catch(() => ({})))) await rm(stage, { recursive: true });
168
197
  }
169
- }
170
- if (!lockInfo) throw failure("Runtime placement is busy or an earlier installation was interrupted. Inspect the installation lock before retrying.", "DEPLOYMENT_BUSY");
171
- let stage, stageInfo;
172
- try {
173
- const existing = await lstat(target).catch(e => { if (e.code !== "ENOENT") throw e; return null; });
174
- if (existing) return { ...await verifyDeployment(target, distribution), codex_home: canonicalHome, reused: true };
175
- stage = await mkdtemp(path.join(root, ".stage-")); stageInfo = await ownedDirectory(stage);
176
- for (const file of distribution.manifest.files) {
177
- const parts = file.path.split("/"); parts.pop(); let parent = stage;
178
- for (const part of parts) { parent = path.join(parent, part); await ownedDirectory(parent, true); }
179
- await writeExclusive(path.join(stage, file.path), distribution.contents.get(file.path), file.mode);
198
+ });
199
+ }
200
+
201
+ // A launcher is named by its own content, so reuse and tamper detection are
202
+ // one comparison. An existing file is verified, never rewritten.
203
+ export async function placeLauncher(deployment, kind, script) {
204
+ const bytes = Buffer.from(script), stateRoot = path.join(deployment.codex_home, "codex-steer"), bin = path.join(stateRoot, "bin");
205
+ await ownedDirectory(stateRoot); await ownedDirectory(bin, true);
206
+ const name = `codexteer-${kind}-${hash(bytes).slice(0, 12)}`, file = path.join(bin, name);
207
+ return exclusively(path.join(bin, `.install-${name}`), async () => {
208
+ if (!await present(file)) {
209
+ const stage = path.join(bin, `.stage-${randomBytes(8).toString("hex")}`);
210
+ try { await writeExclusive(stage, bytes, 0o700); await rename(stage, file); }
211
+ finally { await rm(stage, { force: true }); }
180
212
  }
181
- await writeExclusive(path.join(stage, RECORD), JSON.stringify({ manifest: distribution.manifest, sha256: distribution.sha256 }) + "\n", 0o600);
182
- await verifyDeployment(stage, distribution);
183
- // All cooperating installers hold the exclusive lock, and the parent is
184
- // private. Refuse even an empty existing directory rather than replacing it.
185
- if (await lstat(target).catch(e => { if (e.code !== "ENOENT") throw e; return null; })) throw failure("Deployment destination appeared during placement.");
186
- await rename(stage, target); stage = null;
187
- return { ...await verifyDeployment(target, distribution), codex_home: canonicalHome, reused: false };
188
- } finally {
189
- if (stage && sameFile(stageInfo, await lstat(stage).catch(() => ({})))) await rm(stage, { recursive: true });
190
- if (sameFile(lockInfo, await lstat(lock).catch(() => ({})))) await rm(lock, { recursive: true });
191
- }
213
+ const { bytes: found, info } = await readSafe(file, true);
214
+ if ((info.mode & 0o777) !== 0o700 || !found.equals(bytes)) throw failure(`The launcher ${file} differs from the one this CLI generates. It was left unchanged; ask the user to inspect or remove it before generating again.`, "LAUNCHER_MODIFIED");
215
+ return file;
216
+ });
192
217
  }
package/src/findings.mjs CHANGED
@@ -2,7 +2,8 @@ import { randomUUID } from "node:crypto";
2
2
  import { normalizeTaskId } from "./task-id.mjs";
3
3
  import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
4
4
  import { collectEvidence } from "./evidence.mjs";
5
- import { decodeCursor, digest } from "./observe.mjs";
5
+ import { digest } from "./observe.mjs";
6
+ import { parseCursor, withCursorReference } from "./cursor.mjs";
6
7
  import { verifyCheckpoint } from "./checkpoint.mjs";
7
8
 
8
9
  const failure = (message, code) => Object.assign(new Error(message), { code });
@@ -14,6 +15,7 @@ const text = (value, label, max = 8000) => {
14
15
  // A cursor can advance because our own intervention appeared as a userMessage.
15
16
  // It is observation context, never sufficient evidence for another intervention.
16
17
  const contextHash = record => digest({ condition: record.condition, evidence: record.evidence });
18
+ const presentFinding = record => ({ ...withCursorReference(record), ...(Array.isArray(record.revisions) ? { revisions: record.revisions.map(withCursorReference) } : {}) });
17
19
 
18
20
  export async function getFinding(threadInput, id, storage = {}) {
19
21
  const threadId = normalizeTaskId(threadInput), record = await readRecord("findings", id, storage);
@@ -33,7 +35,7 @@ export async function createFinding(threadInput, { key, title, condition, eviden
33
35
  const threadId = normalizeTaskId(threadInput);
34
36
  if (typeof key !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,150}$/.test(key)) throw failure("--key requires a stable short identifier for this concern.", "FINDING_INVALID");
35
37
  text(title, "--title", 240); text(condition, "--condition");
36
- const refs = await collectEvidence(evidence), observed = basedOn ? decodeCursor(basedOn, threadId) : null;
38
+ const refs = await collectEvidence(evidence), observed = basedOn ? parseCursor(basedOn, threadId) : null;
37
39
  return withStoreLock(`message-${threadId}`, async () => {
38
40
  const existing = (await listRecords("findings", storage)).find(record => record?.thread_id === threadId && record.key === key);
39
41
  if (existing) return { ...await findingStatus(threadId, existing.id, storage), reused: true };
@@ -42,7 +44,7 @@ export async function createFinding(threadInput, { key, title, condition, eviden
42
44
  evidence: refs, based_on: basedOn ?? null, user_revision: observed?.user_revision ?? null, disposition: "open", resolution: null,
43
45
  evaluation: { rating: "unrated" }, revisions: [], created_at: now, updated_at: now };
44
46
  await writeRecord("findings", record.id, record, storage);
45
- return { ...record, status: "open", messages: [], verification: null, reused: false };
47
+ return presentFinding({ ...record, status: "open", messages: [], verification: null, reused: false });
46
48
  }, storage);
47
49
  }
48
50
 
@@ -76,7 +78,7 @@ export async function findingStatus(threadInput, id, storage = {}) {
76
78
  } catch (error) { verification = { valid: false, checkpoint_id: record.resolution.checkpoint_id, error_code: error.code ?? "CHECKPOINT_UNAVAILABLE", condition_assessment: "explicit_report" }; }
77
79
  if (!verification.valid && record.disposition === "resolved") status = "needs_review";
78
80
  }
79
- return { ...record, status, messages, verification };
81
+ return presentFinding({ ...record, status, messages, verification });
80
82
  }
81
83
 
82
84
  export async function listFindings(threadInput, { all = false, limit = 50, ...storage } = {}) {
@@ -130,7 +132,7 @@ export async function reopenFinding(threadInput, id, { reason, evidence, basedOn
130
132
  const threadId = normalizeTaskId(threadInput);
131
133
  text(reason, "--reason"); if (condition !== undefined) text(condition, "--condition");
132
134
  const refs = evidence === undefined ? undefined : await collectEvidence(evidence);
133
- const observed = basedOn === undefined ? undefined : decodeCursor(basedOn, threadId);
135
+ const observed = basedOn === undefined ? undefined : parseCursor(basedOn, threadId);
134
136
  return changeFinding(threadId, id, async record => {
135
137
  await knownDelivery(record, storage);
136
138
  const updated = { ...record, condition: condition ?? record.condition, evidence: refs ?? record.evidence, based_on: basedOn ?? record.based_on, user_revision: observed?.user_revision ?? record.user_revision };
@@ -14,7 +14,7 @@ const ATTEMPT_WEB_ONLY = "history show|check --attemptはWeb Cloud専用。Deskt
14
14
 
15
15
  const ENV_VS_PROJECTS = "environments listはCloud実行環境構成、--connection desktop-cloud projects listはDesktopのプロジェクト一覧で、別物。";
16
16
 
17
- const REMOTE_KEEP_ROUTE = "生成された--host・--remote-transport・--context-thread・--supervisor・--connectionを全コマンドでそのまま保持する。";
17
+ const REMOTE_KEEP_ROUTE = "生成された実行コマンドをそのまま使う。--host・--remote-transport・--context-thread・--supervisor・--connectionはその中に固定され、付け直さない。";
18
18
  const REMOTE_ROUTES_SEPARATE = "native・control・pairedのカーソルと監督IDは交換できない。経路が違えばSTALE_CURSORまたはSUPERVISOR_MISMATCH。";
19
19
  const REMOTE_NO_FALLBACK = "失敗しても別ホスト・別経路・UI・SSHへの自動切替や再起動はしない。";
20
20
 
@@ -87,7 +87,7 @@ export const topics = {
87
87
  "通常sendは実行中の親ターンを指定し、それを中断して同じタスクの後続ターンを始める。",
88
88
  "送信直前に親ターンとユーザー入力の鮮度を再確認するが、サーバー側での原子的な固定は保証しない。",
89
89
  CLOUD_COMMON_SUPERVISION,
90
- "監督中は生成された--supervisorと--connection cloudを全コマンドで保持する。",
90
+ "監督中は、--supervisorと--connection cloudを固定した生成済みの実行コマンドをそのまま使う。",
91
91
  "pauseは送信を、stopは観測と送信を拒否する。",
92
92
  "pause・stopはCloud上の作業自体を取り消さない。",
93
93
  "watch --streamは止めるまで続き、既存Monitorのdigest・接続通知・復帰待ちを使う。",
@@ -6,5 +6,5 @@ export const NODE_VERSION_MISMATCH = { code: "NODE_VERSION_MISMATCH", when: "保
6
6
  export const RUNTIME_UNAVAILABLE = { code: "RUNTIME_UNAVAILABLE", when: "既定sharedの接続先(desktop startで起動する共有App Server)の実行記録やソケットが見つからない。", action: "doctorで確認してユーザーへ報告する。作業中のDesktopを終了・再起動しない。復旧(作業後にDesktopを終了してdesktop start)はユーザーが行う。" };
7
7
  export const RUNTIME_NOT_READY = { code: "RUNTIME_NOT_READY", when: "共有App Serverの記録はあるが、そのプロセスかDesktopとの接続が生きていない。", action: "doctorで確認してユーザーへ報告する。作業中のDesktopを終了・再起動しない。復旧(作業後にDesktopを終了してdesktop start)はユーザーが行う。" };
8
8
  export const DUPLICATE_INTERVENTION = { code: "DUPLICATE_INTERVENTION", when: "send --findingで、その指摘の同じrevisionにaccepted/unknownの送信がある(dry-runでも判定)。", action: "再送しない。error.existing_message_idをhistory checkして対応を待つ。新しい根拠か解決条件の変更があるときだけfindings reopenする。" };
9
- export const OPTIONS_INVALID = { code: "OPTIONS_INVALID", when: "ローカル経路の引数が不正(未知のオプション、値の欠落・範囲外、IDやリンクの形式、必須引数の不足)。検証で止まり、送信もコマンド実行もしていない。", action: "error.messageとそのトピックのusage・optionsで引数を直して実行し直す。Cloud経路の同種のエラーはCLOUD_OPTIONS_INVALID、Desktop CloudはDESKTOP_CLOUD_OPTIONS_INVALID。" };
9
+ export const OPTIONS_INVALID = { code: "OPTIONS_INVALID", when: "ローカル経路の引数が不正(未知のオプション、値の欠落・範囲外、IDやリンクの形式、必須引数の不足)。-- より前の未知のオプションは、エラー文とerror.unknown_optionsがその語(先頭から3件、各40文字まで)を示す。--supervisor・--connection・--require-node-version・--host・--remote-transport・--context-threadの重複はCloud経路でもこのコード。検証で止まり、送信もコマンド実行もしていない。", action: "error.messageとそのトピックのusage・optionsで引数を直して実行し直す。Cloud経路の同種のエラーはCLOUD_OPTIONS_INVALID、Desktop CloudはDESKTOP_CLOUD_OPTIONS_INVALID。" };
10
10
  export const STORE_UNSAFE = { code: "STORE_UNSAFE", when: "CODEX_HOME/codex-steer/以下の記録やロックのファイル・ディレクトリが本人所有でない、group/otherに権限がある、シンボリックリンク、または種類が違う。", action: "自動修復しない。操作を止め、ユーザーに所有者と権限の確認を依頼する。" };
@@ -106,7 +106,7 @@ export const topics = {
106
106
  ],
107
107
  notes: [
108
108
  "-- 以降はオプションとして解釈しない。--dry-runや--jsonも本文として送られる。本文の途中に置いた -- は本文に残る。",
109
- "-- より前の未知の--オプションや値のないオプションはOPTIONS_INVALID(Cloud・Desktop CloudはCLOUD_OPTIONS_INVALID)で拒否し、何も送らない。",
109
+ "-- より前の未知の--オプションや値のないオプションはOPTIONS_INVALID(Cloud・Desktop CloudはCLOUD_OPTIONS_INVALID)で拒否し、何も送らない。エラー文とerror.unknown_optionsがその語(先頭から3件、各40文字まで)を示す。",
110
110
  "--source・--kind・--evidence・--based-on・--supersedes・--expires-at・--checkpoint・--findingか--supervisorがあると本文の前に見出し行を付け、どれもなければ本文だけを送る。",
111
111
  "見出しは[codexteer <message_id>]・送信者(既定external)・種類(既定review)と、指定に応じた根拠・観測ターン・置換/撤回対象・期限・checkpoint・指摘と解決条件の行。",
112
112
  "decisionは送信者による分類で、新しいユーザー承認を作らない。元のユーザー指示を根拠として示す。",
@@ -73,7 +73,7 @@ export const topics = {
73
73
  { need: "この目的別一覧と全コマンドの概要を表示する(このページ)", command: "help overview" },
74
74
  ],
75
75
  usage: [
76
- "help [TOPIC]",
76
+ "help [TOPIC] [--errors]",
77
77
  "projects list",
78
78
  "threads list [--desktop-only] [--limit N]",
79
79
  "thread create --project ID|NAME [--context-thread THREAD-ID] <MESSAGE>",
@@ -106,8 +106,9 @@ export const topics = {
106
106
  ],
107
107
  options: [
108
108
  { name: "TOPIC", default: "overview", text: "上の一覧のトピック名(例: send、supervise prompt)。<COMMAND> --helpでも同じ内容を表示する。" },
109
+ { name: "--errors", text: "そのトピックのエラーと対処の一覧だけを表示する。通常のヘルプには載せない。" },
109
110
  ],
110
- returns: "--jsonを付けると、成功時は{ok:true, command, data}、失敗時は{ok:false, error:{message, code, …}}を標準出力へ1行で返し、失敗時の終了コードは1。対話起動のsupervise <THREAD>とorchestrate(promptなし)は--json非対応で、エージェントの標準入出力と終了コードをそのまま引き継ぐ。watch --streamは常にJSON Lines。",
111
+ returns: "--jsonを付けると、成功時は{ok:true, command, data}、失敗時は{ok:false, error:{message, code, help, action, …}}を標準出力へ1行で返し、失敗時の終了コードは1。helpは該当トピック、actionはそのトピックに記載した対処で、引数エラー(OPTIONS_INVALIDなど)はusageに失敗したサブコマンドの使い方も返す。対話起動のsupervise <THREAD>とorchestrate(promptなし)は--json非対応で、エージェントの標準入出力と終了コードをそのまま引き継ぐ。watch --streamは常にJSON Lines。",
111
112
  examples: [
112
113
  "codexteer supervise <THREAD>",
113
114
  "codexteer supervise <THREAD> --use-path-cli",
@@ -372,6 +373,7 @@ export const topics = {
372
373
  "thread repair <THREAD> [--dry-run | --apply]",
373
374
  "--connection cloud thread create --project ENV_ID_OR_NAME [--branch BRANCH] [--dry-run] <MESSAGE...>|-",
374
375
  "--connection cloud thread receipt <ATTEMPT_ID>",
376
+ "--connection cloud|desktop-cloud thread resolve <THREAD>",
375
377
  "--connection desktop-cloud thread create --environment ID|NAME [--title TEXT] [--dry-run] <MESSAGE...>|-",
376
378
  "--connection desktop-cloud thread assign TARGET --project ID|NAME",
377
379
  "--connection desktop-cloud thread receipt <ATTEMPT_ID> [--resolve-unknown --note TEXT]",