codexteer 0.18.2 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +13 -5
  3. package/bin/codexteer.mjs +1 -1
  4. package/docs/distribution.md +23 -6
  5. package/docs/orchestration.md +4 -4
  6. package/package.json +1 -1
  7. package/src/app-server.mjs +4 -4
  8. package/src/checkpoint.mjs +10 -10
  9. package/src/cli.mjs +90 -79
  10. package/src/cloud-cli.mjs +29 -33
  11. package/src/connection-error.mjs +7 -0
  12. package/src/cursor.mjs +370 -0
  13. package/src/desktop-cloud-id.mjs +9 -1
  14. package/src/desktop-cloud-project.mjs +37 -4
  15. package/src/desktop-cloud.mjs +10 -2
  16. package/src/desktop.mjs +9 -9
  17. package/src/directive.mjs +7 -6
  18. package/src/distribution.mjs +58 -33
  19. package/src/evidence.mjs +3 -3
  20. package/src/findings.mjs +13 -8
  21. package/src/help-cloud-remote.mjs +399 -0
  22. package/src/help-errors.mjs +10 -0
  23. package/src/help-intervention.mjs +492 -0
  24. package/src/help-local.mjs +510 -0
  25. package/src/help-observation.mjs +200 -0
  26. package/src/help-supervision.mjs +257 -0
  27. package/src/help.mjs +126 -304
  28. package/src/journal.mjs +33 -12
  29. package/src/launcher.mjs +6 -6
  30. package/src/monitor.mjs +5 -4
  31. package/src/notify.mjs +3 -3
  32. package/src/observe.mjs +11 -40
  33. package/src/orchestrate-prompt.mjs +14 -12
  34. package/src/orchestrate.mjs +4 -7
  35. package/src/paged-read.mjs +10 -17
  36. package/src/prompt-command.mjs +45 -7
  37. package/src/prompt.mjs +15 -12
  38. package/src/resource.mjs +6 -6
  39. package/src/runner.mjs +5 -5
  40. package/src/store.mjs +2 -2
  41. package/src/supervise.mjs +5 -7
  42. package/src/task-id.mjs +29 -4
  43. package/src/thread-id.mjs +5 -5
  44. package/src/unknown-options.mjs +17 -0
  45. package/src/wrapper.mjs +2 -2
package/src/cloud-cli.mjs CHANGED
@@ -1,18 +1,22 @@
1
- import { desktopCloudProjectDoctor, assignDesktopCloudProject, desktopCloudProjectReceipt } from "./desktop-cloud-project.mjs";
1
+ import { desktopCloudProjectDoctor, assignDesktopCloudProject, desktopCloudProjectReceipt, resolveDesktopCloudProjectAttempt } from "./desktop-cloud-project.mjs";
2
2
  import { listProjects as listDesktopProjects } from "./projects.mjs";
3
3
  import { isDesktopCloudTask, desktopCloudId, desktopCloudLink } from "./desktop-cloud-id.mjs";
4
4
  import { desktopCloudConnection, desktopCloudEnvironments } from "./desktop-cloud-api.mjs";
5
5
  import { createDesktopCloudTask, listDesktopCloudTasks, desktopCloudReceipt } from "./desktop-cloud.mjs";
6
- import { isCloudTask } from "./task-id.mjs";
6
+ import { isCloudTask, selectedCloudInvocation } from "./task-id.mjs";
7
7
  import { cloudTaskId } from "./cloud-id.mjs";
8
+ import { normalizeThreadId } from "./thread-id.mjs";
9
+ import { connectionMismatch } from "./connection-error.mjs";
8
10
  import { cloudConnection } from "./cloud-connection.mjs";
9
11
  import { observeThread, printObservation } from "./observe.mjs";
10
12
  import { monitorCommand } from "./monitor.mjs";
11
13
  import { notificationOptions } from "./notify.mjs";
12
14
  import { prepareSupervisorPrompt, superviseAgent } from "./supervise.mjs";
15
+ import { rejectRepeatedBinding } from "./prompt-command.mjs";
16
+ import { rejectUnknownOptions } from "./unknown-options.mjs";
13
17
  import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, assertSupervisorRegistration, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
14
18
  import { createFinding, findingStatus, listFindings, resolveFinding, dismissFinding, reopenFinding, evaluateFinding, findingStats } from "./findings.mjs";
15
- import { sendTrackedMessage, listMessages, getMessage, messageSummary, reconcileMessages, markMessage, listInstructions } from "./journal.mjs";
19
+ import { sendTrackedMessage, listMessages, getMessage, messageSummary, reconcileMessages, markMessage, resolveUnknownDelivery, listInstructions } from "./journal.mjs";
16
20
  import { createCloudTask, cloudTaskDiff, listCloudTasks, cloudCreationReceipt } from "./cloud.mjs";
17
21
  import { cloudSendReceipt, reconcileCloudSend } from "./cloud-observe.mjs";
18
22
  import { cloudEnvironments } from "./cloud-api.mjs";
@@ -24,7 +28,6 @@ import { createCheckpoint, listCheckpoints, getCheckpoint, checkpointSummary, ve
24
28
  import { acquireResource, resourceStatus, renewResource, releaseResource, listResources, runWithResource } from "./resource.mjs";
25
29
 
26
30
  const failure = (message, code = "CLOUD_OPTIONS_INVALID") => Object.assign(new Error(message), { code, execution_environment: "cloud" });
27
- const values = new Set("--environment --connection --supervisor --require-node-version --host --remote-transport --context-thread --thread --backend --source --kind --evidence --based-on --finding --expires-at --supersedes --checkpoint --limit --max-chars --since --poll-ms --timeout-ms --until --notify --settle-ms --max-hold-ms --owner --key --condition --note --rating --reason --root --path --exclude --command --cwd --artifact --status --by --token --ttl-ms --agent --project --env --branch --title --model --thinking --cursor --attempt".split(" "));
28
31
  function option(args, name, fallback) {
29
32
  const index = args.indexOf(name), stop = args.indexOf("--");
30
33
  if (index < 0 || (stop >= 0 && index > stop)) return fallback;
@@ -40,35 +43,26 @@ const repeated = (args, name) => { const result = []; let value; while ((value =
40
43
  const message = args => { if (args[0] === "--") args.shift(); return args.length === 1 && args[0] === "-" ? readFileSync(0, "utf8") : args.join(" "); };
41
44
  function exact(args, count) { if (args.length !== count || args.some(arg => arg.startsWith("--"))) throw failure("Unexpected arguments. See the command's help."); }
42
45
 
43
- function selectedCloudInvocation(argv) {
44
- const end = argv.indexOf("--"), own = end < 0 ? argv : argv.slice(0, end);
45
- const connection = own.indexOf("--connection");
46
- const positional = [];
47
- for (let i = 0; i < own.length; i++) {
48
- if (values.has(own[i])) { i++; continue; }
49
- if (!own[i].startsWith("--")) positional.push(own[i]);
50
- }
51
- const [command, action] = positional;
52
- if (["help", "--help", "--version"].includes(command) || own.includes("--help") || own.includes("--version")) return false;
53
- if (connection >= 0 && ["cloud", "desktop-cloud"].includes(own[connection + 1])) return own[connection + 1];
54
- const thread = own.indexOf("--thread");
55
- if (["doctor", "resource"].includes(command) && thread >= 0) return isDesktopCloudTask(own[thread + 1]) ? "desktop-cloud" : isCloudTask(own[thread + 1]) ? "cloud" : false;
56
- if (command === "thread" && action === "create") return false;
57
- const nested = ["history", "instructions", "findings", "checkpoint", "thread"].includes(command)
58
- || (command === "supervise" && ["prompt", "register", "status", "pause", "resume", "stop", "list"].includes(action));
59
- const target = isCloudTask(command) || isDesktopCloudTask(command) ? command : positional[nested ? 2 : 1];
60
- return isDesktopCloudTask(target) ? "desktop-cloud" : isCloudTask(target) ? "cloud" : false;
61
- }
62
-
63
46
  export const cloudInvocation = argv => !!selectedCloudInvocation(argv);
64
47
 
65
48
  // Only parsing/routing is backend-specific. Supervision, monitoring, directives,
66
49
  // findings, journaling and saved-agent launch use the same local implementation.
67
50
  export async function mainCloud(argv) {
51
+ rejectRepeatedBinding(argv);
68
52
  const args = [...argv], json = flag(args, "--json"), connection = option(args, "--connection", selectedCloudInvocation(argv) || "cloud");
69
53
  const desktopCloud = connection === "desktop-cloud";
70
- const targetId = desktopCloud ? desktopCloudId : cloudTaskId;
71
- if (!["cloud", "desktop-cloud"].includes(connection)) throw failure("A cloud task requires the cloud connection.", "CLOUD_TARGET_CONFLICT");
54
+ const targetId = desktopCloud ? desktopCloudId : value => {
55
+ try { return cloudTaskId(value); }
56
+ catch (error) {
57
+ // Preserve the Web Cloud ID validator's existing error code.
58
+ if (isDesktopCloudTask(value)) throw connectionMismatch("desktop-cloud", connection, error.code);
59
+ let local = false;
60
+ try { normalizeThreadId(value); local = true; } catch {}
61
+ if (local) throw connectionMismatch("shared", connection, error.code);
62
+ throw error;
63
+ }
64
+ };
65
+ if (!["cloud", "desktop-cloud"].includes(connection)) throw Object.assign(connectionMismatch(selectedCloudInvocation(argv) || "cloud", connection), { execution_environment: "cloud" });
72
66
  const own = args.slice(0, args.includes("--") ? args.indexOf("--") : args.length);
73
67
  if (own.some(value => ["--host", "--remote-transport", "--context-thread"].includes(value))) throw failure("Desktop host routes cannot select a cloud task.", "CLOUD_TARGET_CONFLICT");
74
68
  const requiredNode = option(args, "--require-node-version");
@@ -159,11 +153,12 @@ export async function mainCloud(argv) {
159
153
  const settings = { taskId: id };
160
154
  output(`history.${action}`, action === "show" ? await cloudSendReceipt(attempt, settings) : await reconcileCloudSend(attempt, settings)); return;
161
155
  }
162
- const includeText = flag(args, "--include-text"), pending = flag(args, "--pending");
163
- const report = action === "mark" ? { status: option(args, "--status"), note: option(args, "--note"), evidence: repeated(args, "--evidence"), by: option(args, "--by", "local") } : {};
156
+ const includeText = flag(args, "--include-text"), pending = flag(args, "--pending"), resolveUnknown = desktopCloud && action === "mark" && flag(args, "--resolve-unknown");
157
+ const report = resolveUnknown ? { note: option(args, "--note") } : action === "mark" ? { status: option(args, "--status"), note: option(args, "--note"), evidence: repeated(args, "--evidence"), by: option(args, "--by", "local") } : {};
164
158
  if (!args[0] || args.length > 2 || !["list", "show", "check", "mark"].includes(action) || (["show", "mark"].includes(action) && !args[1]) || (action === "list" && args[1])) throw failure("Invalid history arguments.");
159
+ if (resolveUnknown && supervisor) throw failure("A supervisor cannot release its own unknown delivery. The user must run this without --supervisor.", "SUPERVISOR_CONTROL_FORBIDDEN");
165
160
  const id = await bound(args[0]); const data = action === "list" ? await listMessages(id, { includeText, pending }) : action === "show" ? messageSummary(await getMessage(id, args[1]), includeText)
166
- : action === "check" ? await reconcileMessages(id, args[1], { includeText }, dependencies) : await markMessage(id, args[1], report);
161
+ : action === "check" ? await reconcileMessages(id, args[1], { includeText }, dependencies) : resolveUnknown ? await resolveUnknownDelivery(id, args[1], report) : await markMessage(id, args[1], report);
167
162
  output(`history.${action}`, data); return;
168
163
  }
169
164
  if (command === "instructions") {
@@ -225,7 +220,7 @@ export async function mainCloud(argv) {
225
220
  args.shift(); if (supervisor) throw failure("A bound supervisor cannot create another task.", "SUPERVISOR_CONTROL_FORBIDDEN");
226
221
  const project = option(args, "--project"), requestedBranch = option(args, "--branch"), dryRun = flag(args, "--dry-run");
227
222
  if (project === undefined) throw failure("Cloud creation requires --project with an environment ID or exact name.");
228
- const end = args.includes("--") ? args.indexOf("--") : args.length; if (args.slice(0, end).some(arg => arg.startsWith("--"))) throw failure("Unknown cloud creation option; explicit model/title/permission overrides are not implemented yet.");
223
+ const end = args.includes("--") ? args.indexOf("--") : args.length; rejectUnknownOptions(args.slice(0, end), "cloud creation", "Explicit model/title/permission overrides are not implemented yet.", failure);
229
224
  const body = message(args); if (!body.trim()) throw failure("A non-empty initial instruction is required.");
230
225
  const rows = (await cloudEnvironments()).environments, selector = project, matches = rows.filter(e => e.id === selector || e.name === selector);
231
226
  if (matches.length !== 1) throw failure("The cloud environment selector must match one accessible ID or exact name.");
@@ -271,7 +266,7 @@ export async function mainCloud(argv) {
271
266
  const options = { dryRun, newTurn, supervisor, connection, evidence: repeated(args, "--evidence") };
272
267
  for (const [key, name] of Object.entries({ source: "--source", kind: "--kind", basedOn: "--based-on", supersedes: "--supersedes", expiresAt: "--expires-at", checkpoint: "--checkpoint", finding: "--finding" })) { const value = option(args, name); if (value !== undefined) options[key] = value; }
273
268
  const id = targetId(args.shift()), end = args.includes("--") ? args.indexOf("--") : args.length;
274
- if (args.slice(0, end).some(arg => arg.startsWith("--"))) throw failure("Unknown send option. Use -- before instruction text starting with --.");
269
+ rejectUnknownOptions(args.slice(0, end), "send", "Put instruction text that starts with -- after --.", failure);
275
270
  const body = message(args); if (!body.trim()) throw failure("send requires a non-empty message.");
276
271
  const receipt = await sendTrackedMessage(id, body, options);
277
272
  receipt.sound = noSound ? { played: false, reason: "disabled" } : playSendSound(receipt);
@@ -296,11 +291,12 @@ async function desktopCloudCommands(command, args, { supervisor, output, bound,
296
291
  if (action === "create") {
297
292
  const environment = option(args, "--environment"), project = option(args, "--project"), title = option(args, "--title"), dryRun = flag(args, "--dry-run");
298
293
  const end = args.includes("--") ? args.indexOf("--") : args.length;
299
- if (args.slice(0, end).some(arg => arg.startsWith("--"))) throw failure("Unknown Desktop Cloud creation option.");
294
+ rejectUnknownOptions(args.slice(0, end), "Desktop Cloud creation", "Put an instruction that starts with -- after --.", failure);
300
295
  output("thread.create", await createDesktopCloudTask(environment, message(args), { project, title, dryRun })); return true;
301
296
  }
302
297
  if (action === "assign") { const project = option(args, "--project"); exact(args, 1); const data = await assignDesktopCloudProject(await bound(args[0]), project); output("thread.assign", data); if (data.verification_status !== "verified") process.exitCode = 1; return true; }
303
- exact(args, 1);
298
+ const resolveUnknown = action === "receipt" && flag(args, "--resolve-unknown"), note = resolveUnknown ? option(args, "--note") : undefined; exact(args, 1);
299
+ if (resolveUnknown) { output("thread.receipt", await resolveDesktopCloudProjectAttempt(args[0], { note })); return true; }
304
300
  if (action === "receipt") { let data; try { data = await desktopCloudReceipt(args[0]); } catch (error) { if (error.code !== "DESKTOP_CLOUD_CREATION_RECEIPT_NOT_FOUND") throw error; data = await desktopCloudProjectReceipt(args[0]); } output("thread.receipt", data); return true; }
305
301
  if (action === "resolve") { const id = await bound(args[0]); output("thread.resolve", { thread_id: id, deep_link: desktopCloudLink(id) }); return true; }
306
302
  throw failure("Expected thread create|assign|receipt|resolve.");
@@ -0,0 +1,7 @@
1
+ // Diagnostics only: callers retain their existing routing and rejection rules.
2
+ export function connectionMismatch(expected, selected, code = "CLOUD_TARGET_CONFLICT") {
3
+ const message = expected === "shared"
4
+ ? `For a local task, use --connection shared (the default local connection; got ${selected}).`
5
+ : `This task requires --connection ${expected} (got ${selected}).`;
6
+ return Object.assign(new Error(message), { code, expected_connection: expected, connection: selected });
7
+ }
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
+ }
@@ -1,11 +1,19 @@
1
1
  import { normalizeThreadId } from "./thread-id.mjs";
2
+ import { isCloudTask } from "./cloud-id.mjs";
3
+ import { connectionMismatch } from "./connection-error.mjs";
2
4
  export function isDesktopCloudTask(value) {
3
5
  if (typeof value !== "string") return false;
4
6
  if (value.startsWith("durable_")) return true;
5
7
  try { const u = new URL(value); return u.protocol === "codex:" && u.hostname === "threads" && u.searchParams.getAll("hostId").includes("durable"); } catch { return false; }
6
8
  }
7
9
  export function desktopCloudId(value) {
8
- if (!isDesktopCloudTask(value)) throw Object.assign(new Error("Desktop Cloud requires a durable_ ID or a link with hostId=durable."), { code: "CLOUD_TARGET_CONFLICT" });
10
+ if (!isDesktopCloudTask(value)) {
11
+ if (isCloudTask(value)) throw connectionMismatch("cloud", "desktop-cloud");
12
+ let local = false;
13
+ try { normalizeThreadId(value); local = true; } catch {}
14
+ if (local) throw connectionMismatch("shared", "desktop-cloud");
15
+ throw Object.assign(new Error("Desktop Cloud requires a durable_ ID or a link with hostId=durable."), { code: "CLOUD_TARGET_CONFLICT" });
16
+ }
9
17
  let uuid = value.slice(8);
10
18
  if (!value.startsWith("durable_")) {
11
19
  const url = new URL(value);
@@ -13,6 +13,7 @@ import { listProjects } from "./projects.mjs";
13
13
  import { listRecords, readRecord, writeRecord, withStoreLock, storePath } from "./store.mjs";
14
14
  const failure = (message, code = "DESKTOP_CLOUD_PROJECT_UNAVAILABLE", uncertain = false) => Object.assign(new Error(message), { code, uncertain });
15
15
  const sha = value => createHash("sha256").update(value).digest("hex");
16
+ const shows = (state, projectId) => state?.assignment?.projectKind === "local" && state.assignment.projectId === projectId && state.host_id === "durable";
16
17
 
17
18
  export async function discoverProjectApi({ discover = discoverRuntime, endpoint = discoverNativeEndpoint, run = spawnSync } = {}) {
18
19
  const selected = await discover(), { state } = selected;
@@ -100,7 +101,7 @@ export async function desktopCloudProjectDoctor(target, selector, { connect = co
100
101
  const state = await readState(api.paths.home, id);
101
102
  report.membership = { status: state.assignment?.projectKind === "local" ? (state.host_id === "durable" ? "registered" : "recorded_host_unverified") : state.projectless ? "projectless" : "source_unknown",
102
103
  project_id: state.assignment?.projectId ?? null, host_id: state.host_id, source: "read-only-desktop-profile-state" };
103
- if (selected) report.membership.matches_selected_project = state.assignment?.projectKind === "local" && state.assignment.projectId === selected.project_id && state.host_id === "durable";
104
+ if (selected) report.membership.matches_selected_project = shows(state, selected.project_id);
104
105
  return report;
105
106
  } finally { try { client?.close(); } catch { /* Preserve diagnostics. */ } }
106
107
  }
@@ -109,7 +110,9 @@ export async function assignDesktopCloudProject(target, selector, { connect = co
109
110
  const id = desktopCloudId(target);
110
111
  if (typeof selector !== "string" || !selector.trim()) throw failure("--project is required.", "PROJECT_REQUIRED");
111
112
  return withStoreLock(`desktop-cloud-project-${id}`, async () => {
112
- if ((await listRecords("desktop-cloud-projects", storage)).some(row => row.thread_id === id && row.delivery_status === "unknown")) throw failure("An earlier assignment is unknown. Inspect its receipt; do not replay it.", "DESKTOP_CLOUD_PROJECT_PENDING");
113
+ const pending = (await confirmFromState(id, (await listRecords("desktop-cloud-projects", storage)).filter(row => row.thread_id === id && row.delivery_status === "unknown"), readState, storage)).find(row => !row.resolution);
114
+ if (pending) throw Object.assign(failure(`An earlier assignment is unknown and is never replayed. Run thread receipt ${pending.attempt_id} --connection desktop-cloud to recheck Desktop's profile state; if it stays unknown, the user (not a supervisor) can release it with --resolve-unknown --note TEXT.`, "DESKTOP_CLOUD_PROJECT_PENDING"),
115
+ { existing_attempt_id: pending.attempt_id, existing_receipt_path: pending.receipt_path });
113
116
  let client, receipt, api, dispatching = false;
114
117
  try {
115
118
  client = await connect(); const environmentId = await targetEnvironment(client, id); api = await discoverApi();
@@ -129,7 +132,7 @@ export async function assignDesktopCloudProject(target, selector, { connect = co
129
132
  receipt.delivery_status = "accepted"; receipt.proof_sha256 = sha(response.payload);
130
133
  await writeRecord("desktop-cloud-projects", attempt, receipt, storage); await check(api);
131
134
  const stored = await readState(api.paths.home, id);
132
- receipt.verification_status = stored.assignment?.projectKind === "local" && stored.assignment.projectId === selected.project_id && stored.host_id === "durable" ? "verified" : "not_observed";
135
+ receipt.verification_status = shows(stored, selected.project_id) ? "verified" : "not_observed";
133
136
  await writeRecord("desktop-cloud-projects", attempt, receipt, storage); return receipt;
134
137
  } catch (error) {
135
138
  if (receipt) {
@@ -141,8 +144,38 @@ export async function assignDesktopCloudProject(target, selector, { connect = co
141
144
  } finally { try { client?.close(); } catch { /* Preserve diagnostics. */ } }
142
145
  }, storage);
143
146
  }
144
- export async function desktopCloudProjectReceipt(attempt, storage = {}) {
147
+ // Absence from Desktop state never proves the assignment was not applied, so
148
+ // only a positive observation rewrites an unknown attempt.
149
+ async function confirmFromState(id, rows, readState, storage) {
150
+ if (!rows.length) return rows;
151
+ let state; try { state = await readState(storage.home ?? codexHome(), id); } catch { return rows; }
152
+ const remaining = [];
153
+ for (const row of rows) {
154
+ if (!shows(state, row.project_id)) { remaining.push(row); continue; }
155
+ await writeRecord("desktop-cloud-projects", row.attempt_id, { ...row, delivery_status: "accepted", verification_status: "verified", delivery_verification: "desktop-profile-state", verified_at: new Date().toISOString() }, storage);
156
+ }
157
+ return remaining;
158
+ }
159
+ async function readAttempt(attempt, storage) {
145
160
  if (typeof attempt !== "string" || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(attempt)) throw failure("Invalid assignment attempt ID.");
146
161
  const row = await readRecord("desktop-cloud-projects", attempt, storage);
147
162
  if (!row || row.type !== "desktop-cloud-project" || row.attempt_id !== attempt) throw failure("Assignment receipt not found."); return row;
148
163
  }
164
+ export async function desktopCloudProjectReceipt(attempt, { readState = readDesktopProjectState, ...storage } = {}) {
165
+ const row = await readAttempt(attempt, storage);
166
+ if (row.delivery_status !== "unknown") return row;
167
+ return withStoreLock(`desktop-cloud-project-${row.thread_id}`, async () => {
168
+ await confirmFromState(row.thread_id, [await readAttempt(attempt, storage)].filter(current => current.delivery_status === "unknown"), readState, storage);
169
+ return readAttempt(attempt, storage);
170
+ }, storage);
171
+ }
172
+ export async function resolveDesktopCloudProjectAttempt(attempt, { note, ...storage } = {}) {
173
+ if (typeof note !== "string" || !note.trim()) throw failure("--resolve-unknown requires a non-empty --note.", "DESKTOP_CLOUD_OPTIONS_INVALID");
174
+ const { thread_id: id } = await readAttempt(attempt, storage);
175
+ return withStoreLock(`desktop-cloud-project-${id}`, async () => {
176
+ const row = await readAttempt(attempt, storage);
177
+ if (row.delivery_status !== "unknown" || row.resolution) throw failure("This assignment attempt has no unresolved unknown delivery.", "DESKTOP_CLOUD_OPTIONS_INVALID");
178
+ const updated = { ...row, resolution: { by: "user", at: new Date().toISOString(), note } };
179
+ await writeRecord("desktop-cloud-projects", attempt, updated, storage); return updated;
180
+ }, storage);
181
+ }