@primitive.ai/prim 0.1.0-alpha.41 → 0.1.0-alpha.43

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.
@@ -1,21 +1,27 @@
1
1
  import {
2
2
  getSiteUrl
3
- } from "./chunk-26VA3ADF.js";
3
+ } from "./chunk-W6PAKEDO.js";
4
4
 
5
5
  // src/journal.ts
6
6
  import {
7
7
  appendFileSync,
8
+ closeSync,
8
9
  existsSync,
10
+ fstatSync,
9
11
  mkdirSync,
12
+ openSync,
13
+ opendirSync,
10
14
  readFileSync,
11
- readdirSync,
12
- statSync
15
+ readSync,
16
+ readdirSync
13
17
  } from "fs";
14
18
  import { homedir } from "os";
15
19
  import { dirname, join } from "path";
16
20
  var JOURNAL_DIR = join(homedir(), ".config", "prim", "moves");
17
21
  var UNBOUND_BUCKET = "_unbound";
18
22
  var JOURNAL_BASENAME = "journal.ndjson";
23
+ var JOURNAL_STATS_SAMPLE_BYTES = 64 * 1024;
24
+ var PENDING_STATS_MAX_FILES = 128;
19
25
  function envSlug(apiUrl) {
20
26
  const slug = apiUrl.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
21
27
  if (slug.length === 0 || slug === "." || slug === "..") {
@@ -48,11 +54,7 @@ function journalPath(orgId) {
48
54
  function appendMove(move, orgId) {
49
55
  appendMoveToPath(journalPath(orgId), move);
50
56
  }
51
- function readMovesFromPath(path) {
52
- if (!existsSync(path)) {
53
- return [];
54
- }
55
- const content = readFileSync(path, "utf-8");
57
+ function parseMoves(content) {
56
58
  const moves = [];
57
59
  for (const line of content.split("\n")) {
58
60
  if (line.length === 0) {
@@ -65,6 +67,12 @@ function readMovesFromPath(path) {
65
67
  }
66
68
  return moves;
67
69
  }
70
+ function readMovesFromPath(path) {
71
+ if (!existsSync(path)) {
72
+ return [];
73
+ }
74
+ return parseMoves(readFileSync(path, "utf-8"));
75
+ }
68
76
  function listBuckets() {
69
77
  const out = [];
70
78
  const envDir = currentEnvDir();
@@ -83,60 +91,164 @@ function listBuckets() {
83
91
  return out;
84
92
  }
85
93
  var FLUSHING_PREFIX = `${JOURNAL_BASENAME}.flushing.`;
94
+ function oldestCapturedAt(moves) {
95
+ let oldest;
96
+ for (const move of moves) {
97
+ if (!Number.isFinite(move.capturedAt)) {
98
+ continue;
99
+ }
100
+ oldest = oldest === void 0 ? move.capturedAt : Math.min(oldest, move.capturedAt);
101
+ }
102
+ return oldest;
103
+ }
104
+ function sampleJournalFile(path, maxBytes = JOURNAL_STATS_SAMPLE_BYTES) {
105
+ const fd = openSync(path, "r");
106
+ try {
107
+ const initial = fstatSync(fd);
108
+ const target = Math.min(initial.size, Math.max(0, maxBytes));
109
+ const buffer = Buffer.allocUnsafe(target);
110
+ let sampledBytes = 0;
111
+ while (sampledBytes < target) {
112
+ const count = readSync(fd, buffer, sampledBytes, target - sampledBytes, sampledBytes);
113
+ if (count === 0) {
114
+ break;
115
+ }
116
+ sampledBytes += count;
117
+ }
118
+ const stat = fstatSync(fd);
119
+ const sampled = stat.size > sampledBytes;
120
+ let content = buffer.subarray(0, sampledBytes).toString("utf-8");
121
+ if (sampled) {
122
+ const lastNewline = content.lastIndexOf("\n");
123
+ content = lastNewline === -1 ? "" : content.slice(0, lastNewline + 1);
124
+ }
125
+ const lines = content.split("\n").filter((line) => line.length > 0);
126
+ const moves = parseMoves(content);
127
+ return {
128
+ sizeBytes: stat.size,
129
+ mtimeMs: stat.mtimeMs,
130
+ lineCount: sampled && stat.size > 0 ? Math.max(1, lines.length) : lines.length,
131
+ oldestCapturedAt: oldestCapturedAt(moves),
132
+ sampled,
133
+ sampledBytes
134
+ };
135
+ } finally {
136
+ closeSync(fd);
137
+ }
138
+ }
86
139
  function parseFlushingPid(name) {
87
140
  const segments = name.slice(FLUSHING_PREFIX.length).split(".");
88
141
  const last = segments.length >= 2 ? segments[segments.length - 1] : void 0;
89
142
  return last !== void 0 && /^[0-9]+$/.test(last) ? Number(last) : void 0;
90
143
  }
91
- function listFlushingInDir(dir, bucket) {
144
+ function listFlushingInDir(dir, bucket, options = {}) {
92
145
  if (!existsSync(dir)) {
93
146
  return [];
94
147
  }
95
148
  const out = [];
96
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
97
- if (!entry.isFile() || !entry.name.startsWith(FLUSHING_PREFIX)) {
98
- continue;
149
+ let remainingBytes = options.sampleBytes ?? JOURNAL_STATS_SAMPLE_BYTES;
150
+ const maxFiles = options.maxFiles ?? Number.POSITIVE_INFINITY;
151
+ const entries = opendirSync(dir);
152
+ try {
153
+ let entry = entries.readSync();
154
+ while (entry && out.length < maxFiles) {
155
+ if (!entry.isFile() || !entry.name.startsWith(FLUSHING_PREFIX)) {
156
+ entry = entries.readSync();
157
+ continue;
158
+ }
159
+ const path = join(dir, entry.name);
160
+ let sample;
161
+ try {
162
+ sample = sampleJournalFile(path, remainingBytes);
163
+ } catch (err) {
164
+ if (err.code === "ENOENT") {
165
+ entry = entries.readSync();
166
+ continue;
167
+ }
168
+ throw err;
169
+ }
170
+ remainingBytes = Math.max(0, remainingBytes - sample.sampledBytes);
171
+ out.push({
172
+ bucket,
173
+ path,
174
+ pid: parseFlushingPid(entry.name),
175
+ ...sample
176
+ });
177
+ entry = entries.readSync();
99
178
  }
100
- const path = join(dir, entry.name);
101
- const stat = statSync(path);
102
- const lineCount = readFileSync(path, "utf-8").split("\n").filter((l) => l.length > 0).length;
103
- out.push({
104
- bucket,
105
- path,
106
- pid: parseFlushingPid(entry.name),
107
- sizeBytes: stat.size,
108
- mtimeMs: stat.mtimeMs,
109
- lineCount
110
- });
179
+ } finally {
180
+ entries.closeSync();
111
181
  }
112
182
  return out;
113
183
  }
114
- function listFlushing() {
184
+ function listFlushing(options = {}) {
115
185
  const envDir = currentEnvDir();
116
186
  if (!existsSync(envDir)) {
117
187
  return [];
118
188
  }
119
189
  const out = [];
190
+ let remainingBytes = options.sampleBytes ?? JOURNAL_STATS_SAMPLE_BYTES;
191
+ const maxFiles = options.maxFiles ?? Number.POSITIVE_INFINITY;
120
192
  for (const entry of readdirSync(envDir, { withFileTypes: true })) {
193
+ if (out.length >= maxFiles) {
194
+ break;
195
+ }
121
196
  if (entry.isDirectory()) {
122
- out.push(...listFlushingInDir(join(envDir, entry.name), entry.name));
197
+ const found = listFlushingInDir(join(envDir, entry.name), entry.name, {
198
+ sampleBytes: remainingBytes,
199
+ maxFiles: maxFiles - out.length
200
+ });
201
+ out.push(...found);
202
+ remainingBytes = Math.max(
203
+ 0,
204
+ remainingBytes - found.reduce((count, file) => count + file.sampledBytes, 0)
205
+ );
123
206
  }
124
207
  }
125
208
  return out;
126
209
  }
127
- function bucketStats() {
128
- return listBuckets().map(({ bucket, path }) => {
129
- const stat = statSync(path);
130
- const content = readFileSync(path, "utf-8");
131
- const lineCount = content.split("\n").filter((l) => l.length > 0).length;
132
- return {
210
+ function bucketStats(options = {}) {
211
+ const out = [];
212
+ let remainingBytes = options.sampleBytes ?? JOURNAL_STATS_SAMPLE_BYTES;
213
+ const maxFiles = options.maxFiles ?? Number.POSITIVE_INFINITY;
214
+ for (const { bucket, path } of listBuckets()) {
215
+ if (out.length >= maxFiles) {
216
+ break;
217
+ }
218
+ let sample;
219
+ try {
220
+ sample = sampleJournalFile(path, remainingBytes);
221
+ } catch (err) {
222
+ if (err.code === "ENOENT") {
223
+ continue;
224
+ }
225
+ throw err;
226
+ }
227
+ remainingBytes = Math.max(0, remainingBytes - sample.sampledBytes);
228
+ out.push({
133
229
  bucket,
134
230
  path,
135
- sizeBytes: stat.size,
136
- mtimeMs: stat.mtimeMs,
137
- lineCount
138
- };
139
- });
231
+ ...sample
232
+ });
233
+ }
234
+ return out;
235
+ }
236
+ function pendingJournalStats() {
237
+ const perKindBytes = Math.floor(JOURNAL_STATS_SAMPLE_BYTES / 2);
238
+ const perKindFiles = Math.floor(PENDING_STATS_MAX_FILES / 2);
239
+ const live = bucketStats({ sampleBytes: perKindBytes, maxFiles: perKindFiles });
240
+ const stranded = listFlushing({ sampleBytes: perKindBytes, maxFiles: perKindFiles });
241
+ const timestamps = [...live, ...stranded].map((entry) => entry.oldestCapturedAt).filter((value) => value !== void 0);
242
+ const liveSampled = live.some((entry) => entry.sampled) || live.length >= perKindFiles;
243
+ const strandedSampled = stranded.some((entry) => entry.sampled) || stranded.length >= perKindFiles;
244
+ return {
245
+ pendingCount: live.reduce((count, entry) => count + entry.lineCount, 0) + stranded.reduce((count, entry) => count + entry.lineCount, 0),
246
+ oldestPendingAt: timestamps.length > 0 ? Math.min(...timestamps) : void 0,
247
+ strandedCount: stranded.reduce((count, entry) => count + entry.lineCount, 0),
248
+ strandedFileCount: stranded.length,
249
+ sampled: liveSampled || strandedSampled,
250
+ strandedSampled
251
+ };
140
252
  }
141
253
 
142
254
  // src/binding.ts
@@ -248,6 +360,7 @@ export {
248
360
  listBuckets,
249
361
  listFlushing,
250
362
  bucketStats,
363
+ pendingJournalStats,
251
364
  SESSIONS_DIR,
252
365
  resolveOrg
253
366
  };
@@ -68,11 +68,35 @@ function stripAnsi(text) {
68
68
  return text.replace(/\u001b\[[0-9;]*m/g, "");
69
69
  }
70
70
 
71
+ // src/ingest-response.ts
72
+ var IngestAcknowledgementError = class extends Error {
73
+ constructor(message) {
74
+ super(message);
75
+ this.name = "IngestAcknowledgementError";
76
+ }
77
+ };
78
+ function requireDurableIngestAcknowledgement(value, expected) {
79
+ if (typeof value !== "object" || value === null) {
80
+ throw new IngestAcknowledgementError("ingest response was not an object");
81
+ }
82
+ const response = value;
83
+ if (response.disposition !== "persisted") {
84
+ throw new IngestAcknowledgementError("ingest response did not confirm persistence");
85
+ }
86
+ if (response.acknowledged !== expected) {
87
+ throw new IngestAcknowledgementError(
88
+ `ingest acknowledged ${String(response.acknowledged)} of ${String(expected)} moves`
89
+ );
90
+ }
91
+ return value;
92
+ }
93
+
71
94
  export {
72
95
  color,
73
96
  dim,
74
97
  bold,
75
98
  colorForArea,
76
99
  stripControlChars,
77
- stripAnsi
100
+ stripAnsi,
101
+ requireDurableIngestAcknowledgement
78
102
  };
@@ -0,0 +1,55 @@
1
+ // src/hooks/decision-feedback-core.ts
2
+ function buildHookOutput(options) {
3
+ const output = {};
4
+ if (options.systemMessage) output.systemMessage = options.systemMessage;
5
+ if (options.additionalContext) {
6
+ output.hookSpecificOutput = {
7
+ hookEventName: "SessionStart",
8
+ additionalContext: options.additionalContext
9
+ };
10
+ }
11
+ return output;
12
+ }
13
+ function writeHookOutput(output, stream = process.stdout) {
14
+ return new Promise((resolve) => {
15
+ let settled = false;
16
+ const finish = (ok, retainErrorListener = false) => {
17
+ if (settled) return;
18
+ settled = true;
19
+ if (!retainErrorListener) stream.off("error", onError);
20
+ resolve(ok);
21
+ };
22
+ const onError = () => {
23
+ if (settled) {
24
+ stream.off("error", onError);
25
+ return;
26
+ }
27
+ finish(false);
28
+ };
29
+ stream.once("error", onError);
30
+ try {
31
+ stream.write(
32
+ `${JSON.stringify(output)}
33
+ `,
34
+ (error) => error ? finish(false, true) : finish(true)
35
+ );
36
+ } catch {
37
+ finish(false);
38
+ }
39
+ });
40
+ }
41
+ async function handoffHookOutput(output, acknowledge, stream = process.stdout) {
42
+ const handedOff = await writeHookOutput(output, stream);
43
+ if (handedOff && acknowledge) {
44
+ try {
45
+ await acknowledge();
46
+ } catch {
47
+ }
48
+ }
49
+ return handedOff;
50
+ }
51
+
52
+ export {
53
+ buildHookOutput,
54
+ handoffHookOutput
55
+ };
@@ -0,0 +1,202 @@
1
+ import {
2
+ getClient
3
+ } from "./chunk-W6PAKEDO.js";
4
+
5
+ // src/decisions/feedback.ts
6
+ var FEEDBACK_PROTOCOL_VERSION = 1;
7
+ var FEEDBACK_DEADLINE_MS = 3e3;
8
+ var MAX_FEEDBACK_EVENTS = 40;
9
+ var MAX_FEEDBACK_MESSAGE_CODE_POINTS = 8e3;
10
+ var MAX_FEEDBACK_INTENT_CODE_POINTS = 180;
11
+ var LEASE_PATH = "/api/cli/decisions/feedback/lease";
12
+ var ACK_PATH = "/api/cli/decisions/feedback/ack";
13
+ var STATUS_PATH = "/api/cli/decisions/feedback/status";
14
+ var SHORT_ID = /^[0-9a-f]{8}$/u;
15
+ var MAX_EVENT_ID_CHARS = 128;
16
+ var MAX_RAW_INTENT_CODE_UNITS = 512;
17
+ var MAX_FEEDBACK_WEB_URL_CHARS = 2048;
18
+ function isRecord(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ function isUnsafeControl(point) {
22
+ const c0OrC1 = point <= 31 || point >= 127 && point <= 159;
23
+ const bidiControl = point === 1564 || point === 8206 || point === 8207 || point >= 8234 && point <= 8238 || point >= 8294 && point <= 8297;
24
+ return c0OrC1 || bidiControl;
25
+ }
26
+ function replaceIsolatedSurrogates(value) {
27
+ let out = "";
28
+ for (let index = 0; index < value.length; index += 1) {
29
+ const unit = value.charCodeAt(index);
30
+ if (unit >= 55296 && unit <= 56319) {
31
+ const next = value.charCodeAt(index + 1);
32
+ if (next >= 56320 && next <= 57343) {
33
+ out += value[index] + value[index + 1];
34
+ index += 1;
35
+ } else {
36
+ out += "\uFFFD";
37
+ }
38
+ } else if (unit >= 56320 && unit <= 57343) {
39
+ out += "\uFFFD";
40
+ } else {
41
+ out += value[index];
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ function normalizeFeedbackIntent(value) {
47
+ const safeUnicode = replaceIsolatedSurrogates(value);
48
+ const whitespace = safeUnicode.replace(/\s+/gu, " ");
49
+ const withoutControls = Array.from(whitespace).filter((character) => !isUnsafeControl(character.codePointAt(0) ?? 0)).join("").replace(/\s+/gu, " ").trim();
50
+ const points = Array.from(withoutControls);
51
+ if (points.length <= MAX_FEEDBACK_INTENT_CODE_POINTS) {
52
+ return withoutControls;
53
+ }
54
+ return `${points.slice(0, MAX_FEEDBACK_INTENT_CODE_POINTS - 1).join("")}\u2026`;
55
+ }
56
+ function parseFeedbackWebUrl(value) {
57
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_FEEDBACK_WEB_URL_CHARS || !/^https:\/\//iu.test(value) || /\s/u.test(value) || replaceIsolatedSurrogates(value) !== value || Array.from(value).some((character) => isUnsafeControl(character.codePointAt(0) ?? 0))) {
58
+ return void 0;
59
+ }
60
+ try {
61
+ const url = new URL(value);
62
+ if (url.protocol !== "https:" || !url.hostname || url.username.length > 0 || url.password.length > 0) {
63
+ return void 0;
64
+ }
65
+ } catch {
66
+ return void 0;
67
+ }
68
+ return value;
69
+ }
70
+ function parseEvent(value) {
71
+ if (!isRecord(value)) return void 0;
72
+ if (typeof value.eventId !== "string" || value.eventId.length === 0 || value.eventId.length > MAX_EVENT_ID_CHARS || Array.from(value.eventId).some((character) => isUnsafeControl(character.codePointAt(0) ?? 0))) {
73
+ return void 0;
74
+ }
75
+ if (!Number.isSafeInteger(value.leaseVersion) || Number(value.leaseVersion) < 1) {
76
+ return void 0;
77
+ }
78
+ if (typeof value.shortId !== "string" || !SHORT_ID.test(value.shortId)) {
79
+ return void 0;
80
+ }
81
+ if (typeof value.intent !== "string" || value.intent.length > MAX_RAW_INTENT_CODE_UNITS) {
82
+ return void 0;
83
+ }
84
+ const intent = normalizeFeedbackIntent(value.intent);
85
+ if (!intent) return void 0;
86
+ const webUrl = value.webUrl === void 0 ? void 0 : parseFeedbackWebUrl(value.webUrl);
87
+ if (value.webUrl !== void 0 && webUrl === void 0) return void 0;
88
+ return {
89
+ eventId: value.eventId,
90
+ leaseVersion: Number(value.leaseVersion),
91
+ shortId: value.shortId,
92
+ intent,
93
+ ...webUrl === void 0 ? {} : { webUrl }
94
+ };
95
+ }
96
+ function parseFeedbackLease(value) {
97
+ if (!isRecord(value) || value.protocolVersion !== FEEDBACK_PROTOCOL_VERSION) return void 0;
98
+ if (value.status === "empty") {
99
+ return value.hasMore === false ? { events: [], hasMore: false } : void 0;
100
+ }
101
+ if (value.status === "unavailable") {
102
+ return value.reason === "organization_unbound" ? { events: [], hasMore: false } : void 0;
103
+ }
104
+ if (value.status !== "leased" || typeof value.hasMore !== "boolean") return void 0;
105
+ if (!Array.isArray(value.events) || value.events.length === 0 || value.events.length > MAX_FEEDBACK_EVENTS) {
106
+ return void 0;
107
+ }
108
+ const events = value.events.map(parseEvent);
109
+ if (events.some((event) => event === void 0)) return void 0;
110
+ const parsedEvents = events;
111
+ if (new Set(parsedEvents.map((event) => event.eventId)).size !== parsedEvents.length) {
112
+ return void 0;
113
+ }
114
+ return { events: parsedEvents, hasMore: value.hasMore };
115
+ }
116
+ function renderFeedback(lease) {
117
+ const lines = [];
118
+ const deliveries = [];
119
+ let pointCount = 0;
120
+ for (const event of lease.events) {
121
+ const line = `[prim] response \u2192 created Decision (dec_${event.shortId}): ${event.intent}${event.webUrl ? ` (${event.webUrl})` : ""}`;
122
+ const extra = Array.from(line).length + (lines.length === 0 ? 0 : 1);
123
+ if (pointCount + extra > MAX_FEEDBACK_MESSAGE_CODE_POINTS) break;
124
+ lines.push(line);
125
+ deliveries.push({ eventId: event.eventId, leaseVersion: event.leaseVersion });
126
+ pointCount += extra;
127
+ }
128
+ if (lines.length === 0) return void 0;
129
+ return { systemMessage: lines.join("\n"), deliveries };
130
+ }
131
+ async function leaseDecisionFeedback(input, dependencies = {}) {
132
+ try {
133
+ const response = await (dependencies.client ?? getClient()).post(
134
+ LEASE_PATH,
135
+ {
136
+ protocolVersion: FEEDBACK_PROTOCOL_VERSION,
137
+ workspaceId: input.workspaceId,
138
+ currentSessionId: input.currentSessionId
139
+ },
140
+ { signal: input.signal, quietRefresh: true }
141
+ );
142
+ const parsed = parseFeedbackLease(response);
143
+ if (!parsed) {
144
+ dependencies.onError?.(
145
+ new Error("server returned an unsupported decision-feedback contract")
146
+ );
147
+ }
148
+ return parsed;
149
+ } catch (error) {
150
+ dependencies.onError?.(error);
151
+ return void 0;
152
+ }
153
+ }
154
+ async function acknowledgeDecisionFeedback(input, dependencies = {}) {
155
+ if (input.deliveries.length === 0 || input.deliveries.length > MAX_FEEDBACK_EVENTS) return false;
156
+ try {
157
+ const response = await (dependencies.client ?? getClient()).post(
158
+ ACK_PATH,
159
+ {
160
+ protocolVersion: FEEDBACK_PROTOCOL_VERSION,
161
+ workspaceId: input.workspaceId,
162
+ deliveries: input.deliveries
163
+ },
164
+ { signal: input.signal, quietRefresh: true }
165
+ );
166
+ const acknowledgedEventIds = isRecord(response) ? response.acknowledgedEventIds : void 0;
167
+ const expectedEventIds = new Set(input.deliveries.map((delivery) => delivery.eventId));
168
+ const valid = isRecord(response) && response.protocolVersion === FEEDBACK_PROTOCOL_VERSION && response.status === "acked" && Array.isArray(acknowledgedEventIds) && acknowledgedEventIds.length <= input.deliveries.length && acknowledgedEventIds.every(
169
+ (id) => typeof id === "string" && id.length > 0 && id.length <= MAX_EVENT_ID_CHARS && expectedEventIds.has(id)
170
+ ) && new Set(acknowledgedEventIds).size === acknowledgedEventIds.length;
171
+ if (!valid) {
172
+ dependencies.onError?.(
173
+ new Error("server returned an unsupported decision-feedback acknowledgement")
174
+ );
175
+ }
176
+ return valid;
177
+ } catch (error) {
178
+ dependencies.onError?.(error);
179
+ return false;
180
+ }
181
+ }
182
+ function parseFeedbackCapability(value) {
183
+ if (!isRecord(value) || value.protocolVersion !== FEEDBACK_PROTOCOL_VERSION) return void 0;
184
+ if (value.status === "available") return { status: "available" };
185
+ if (value.status === "unavailable" && value.reason === "organization_unbound") {
186
+ return { status: "unavailable", reason: "organization_unbound" };
187
+ }
188
+ return void 0;
189
+ }
190
+ async function fetchFeedbackCapability(signal, client = getClient()) {
191
+ const result = parseFeedbackCapability(await client.get(STATUS_PATH, { signal }));
192
+ if (!result) throw new Error("server returned an unsupported decision-feedback contract");
193
+ return result;
194
+ }
195
+
196
+ export {
197
+ FEEDBACK_DEADLINE_MS,
198
+ renderFeedback,
199
+ leaseDecisionFeedback,
200
+ acknowledgeDecisionFeedback,
201
+ fetchFeedbackCapability
202
+ };
@@ -79,7 +79,7 @@ function getSiteUrl() {
79
79
  }
80
80
  return DEFAULT_API_URL;
81
81
  }
82
- async function refreshToken() {
82
+ async function performTokenRefresh(options = {}) {
83
83
  if (!existsSync(REFRESH_TOKEN_PATH)) {
84
84
  return void 0;
85
85
  }
@@ -91,9 +91,11 @@ async function refreshToken() {
91
91
  const response = await fetch(`${siteUrl}/mcp/broker/refresh`, {
92
92
  method: "POST",
93
93
  headers: { "Content-Type": "application/json" },
94
- body: JSON.stringify({ refresh_token: refreshTokenValue })
94
+ body: JSON.stringify({ refresh_token: refreshTokenValue }),
95
+ signal: options.signal
95
96
  });
96
97
  if (!response.ok) {
98
+ if (options.quiet) return void 0;
97
99
  const detail = (await response.text().catch(() => "")).slice(0, 200);
98
100
  process.stderr.write(
99
101
  `[prim] token refresh rejected by broker: ${response.status} ${response.statusText}${detail ? ` \u2014 ${detail}` : ""}
@@ -112,6 +114,24 @@ async function refreshToken() {
112
114
  saveTokenExpiry(data.access_token, data.expires_in);
113
115
  return data.access_token;
114
116
  }
117
+ var _refreshInFlight;
118
+ function refreshToken(options = {}) {
119
+ if (_refreshInFlight) {
120
+ return _refreshInFlight;
121
+ }
122
+ const attempt = performTokenRefresh(options).then((token) => {
123
+ if (token) {
124
+ _cachedToken = token;
125
+ }
126
+ return token;
127
+ }).finally(() => {
128
+ if (_refreshInFlight === attempt) {
129
+ _refreshInFlight = void 0;
130
+ }
131
+ });
132
+ _refreshInFlight = attempt;
133
+ return attempt;
134
+ }
115
135
  var HttpError = class extends Error {
116
136
  status;
117
137
  constructor(status, message) {
@@ -128,7 +148,10 @@ async function request(method, path, body, options) {
128
148
  _cachedToken = getAuthToken();
129
149
  }
130
150
  if (_cachedToken && isTokenExpiringSoon()) {
131
- const newToken = await refreshToken();
151
+ const newToken = await refreshToken({
152
+ signal: options?.signal,
153
+ quiet: options?.quietRefresh
154
+ });
132
155
  if (newToken) {
133
156
  _cachedToken = newToken;
134
157
  }
@@ -147,9 +170,13 @@ async function request(method, path, body, options) {
147
170
  signal: options?.signal
148
171
  });
149
172
  };
150
- let res = await doFetch(_cachedToken);
173
+ const tokenUsed = _cachedToken;
174
+ let res = await doFetch(tokenUsed);
151
175
  if (res.status === 401) {
152
- const newToken = await refreshToken();
176
+ const newToken = _cachedToken && _cachedToken !== tokenUsed ? _cachedToken : await refreshToken({
177
+ signal: options?.signal,
178
+ quiet: options?.quietRefresh
179
+ });
153
180
  if (newToken) {
154
181
  _cachedToken = newToken;
155
182
  res = await doFetch(newToken);
@@ -179,7 +206,6 @@ export {
179
206
  getTokenExpiresAt,
180
207
  getAuthToken,
181
208
  getSiteUrl,
182
- refreshToken,
183
209
  HttpError,
184
210
  getClient
185
211
  };