min-agent 0.3.0 → 0.4.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 (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
package/dist/doom-loop.js CHANGED
@@ -1,43 +1,278 @@
1
1
  /**
2
- * Doom loop detection.
2
+ * Loop / stall detection for the agent run.
3
3
  *
4
- * Detects when the agent calls the same tool with the same arguments
5
- * repeatedly (indicating it's stuck in a loop). After THRESHOLD consecutive
6
- * identical calls, the loop is broken and the agent is informed.
4
+ * 1. Exact identity: the same tool with the same (normalized) arguments,
5
+ * THRESHOLD times in a row halt. Non-consecutive repeats of the same call
6
+ * are counted too (a parallel batch used to flush the old sliding window),
7
+ * with a higher threshold.
8
+ * 2. Web research productivity: a research call is judged by what came back.
9
+ * Rounds that bring new URLs / domains are free — gathering data about many
10
+ * entities legitimately needs many searches. Rounds that bring nothing new
11
+ * build an "unproductive" streak that first steers the model to write, then
12
+ * removes the research tools for the rest of the turn. A hard per-turn cap
13
+ * still applies.
7
14
  *
8
- * Based on opencode's processor.ts doom loop detection.
15
+ * The detector is owned by the outer run loop so auto-continue cannot reset it.
9
16
  */
10
- const THRESHOLD = 3;
17
+ /** Same tool + same args, back to back. */
18
+ export const EXACT_LOOP_THRESHOLD = 3;
19
+ /** Same tool + same args anywhere in the turn (survives parallel batches). */
20
+ export const REPEAT_TOTAL_THRESHOLD = 5;
21
+ /** Research rounds without new sources before steering the model to deliver. */
22
+ export const RESEARCH_STEER_AFTER = 3;
23
+ /** Research rounds without new sources before the research tools are dropped. */
24
+ export const RESEARCH_STOP_AFTER = 5;
25
+ /** Hard ceiling on research calls in a single turn. */
26
+ export const RESEARCH_TOTAL_CAP = 24;
27
+ export const WEB_RESEARCH_TOOLS = new Set(["search_web", "web_fetch"]);
28
+ const ARTIFACT_TOOLS = new Set(["write", "edit", "apply_patch"]);
29
+ export const STEER_PROMPT = "Your last few searches and page fetches returned nothing new. Stop calling search_web and web_fetch. Using only what you already have, produce the user's requested deliverable now (files, code, or a complete answer). If some fields are uncertain, mark them and proceed — do not search again.";
30
+ export const DELIVER_PROMPT = "The research tools have been removed for the rest of this turn. Do not try to search again. Using the information already in this conversation, produce the user's requested deliverable now (write files with the write/edit tools). If a fact is missing, note the gap and still write the output.";
31
+ export const RESEARCH_STUB_RESULT = "Web search and page fetch are disabled for the rest of this turn. Produce the user's requested deliverable from information already in this conversation. If a fact is missing, note the gap and still write the output.";
32
+ export const LOOP_HALT_MESSAGE = "同一操作连续重复了多次,已停止以免空转。发送消息可继续。";
33
+ function positiveInt(value, fallback) {
34
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1)
35
+ return fallback;
36
+ return Math.floor(value);
37
+ }
11
38
  /** Deterministic serialization so equivalent objects compare equal regardless of key order. */
12
39
  function serializeInput(input) {
13
- if (input === null || typeof input !== "object")
14
- return JSON.stringify(input);
15
- if (Array.isArray(input))
16
- return JSON.stringify(input.map(serializeInput));
17
- const sorted = {};
18
- for (const key of Object.keys(input).sort()) {
19
- sorted[key] = serializeInput(input[key]);
40
+ try {
41
+ if (input === null || typeof input !== "object")
42
+ return JSON.stringify(input);
43
+ if (Array.isArray(input))
44
+ return JSON.stringify(input.map(serializeInput));
45
+ const sorted = {};
46
+ for (const key of Object.keys(input).sort()) {
47
+ sorted[key] = serializeInput(input[key]);
48
+ }
49
+ return JSON.stringify(sorted);
50
+ }
51
+ catch {
52
+ return String(input);
53
+ }
54
+ }
55
+ function asRecord(input) {
56
+ if (input && typeof input === "object" && !Array.isArray(input))
57
+ return input;
58
+ return null;
59
+ }
60
+ function normalizeQuery(raw) {
61
+ if (typeof raw !== "string")
62
+ return "";
63
+ return raw.toLowerCase().replace(/\s+/g, " ").trim();
64
+ }
65
+ function hostOf(raw) {
66
+ try {
67
+ return new URL(raw.trim()).hostname.toLowerCase().replace(/^www\./, "");
68
+ }
69
+ catch {
70
+ return "";
71
+ }
72
+ }
73
+ function normalizeUrl(raw) {
74
+ if (typeof raw !== "string")
75
+ return "";
76
+ try {
77
+ const u = new URL(raw.trim());
78
+ const host = u.hostname.toLowerCase().replace(/^www\./, "");
79
+ const path = u.pathname.replace(/\/+$/, "");
80
+ return `${u.protocol}//${host}${path}${u.search}`;
81
+ }
82
+ catch {
83
+ return raw.trim().toLowerCase();
84
+ }
85
+ }
86
+ /** Identity used for exact-repeat detection (search queries / URLs are normalized). */
87
+ export function identityPayload(toolName, input) {
88
+ const rec = asRecord(input);
89
+ if (toolName === "search_web" && rec)
90
+ return serializeInput({ query: normalizeQuery(rec.query) });
91
+ if (toolName === "web_fetch" && rec)
92
+ return serializeInput({ url: normalizeUrl(rec.url) });
93
+ return serializeInput(input);
94
+ }
95
+ const URL_IN_TEXT = /https?:\/\/[^\s"'<>)\]}]+/g;
96
+ const RESULT_ERROR = /^(error|search error|fetch error)\b/i;
97
+ /** New URLs / domains found in a research result, ignoring ones already seen. */
98
+ export function extractSources(text) {
99
+ const urls = new Set();
100
+ const hosts = new Set();
101
+ for (const raw of text.match(URL_IN_TEXT) ?? []) {
102
+ const url = normalizeUrl(raw);
103
+ if (url)
104
+ urls.add(url);
105
+ const host = hostOf(raw);
106
+ if (host)
107
+ hosts.add(host);
20
108
  }
21
- return JSON.stringify(sorted);
109
+ return { urls: [...urls], hosts: [...hosts] };
22
110
  }
23
111
  export class DoomLoopDetector {
24
- recentCalls = [];
25
- /** Record a tool call. Returns true if a doom loop is detected. */
112
+ lastCall = { key: "", count: 0 };
113
+ callCounts = new Map();
114
+ webTotal = 0;
115
+ /** Research calls issued but whose results have not been seen yet. */
116
+ inFlight = 0;
117
+ /** Did the current batch of research calls bring anything new? */
118
+ batchProductive = false;
119
+ unproductiveRounds = 0;
120
+ steered = false;
121
+ capped = false;
122
+ delivered = false;
123
+ seenUrls = new Set();
124
+ seenHosts = new Set();
125
+ steerAfter;
126
+ stopAfter;
127
+ totalCap;
128
+ constructor(opts) {
129
+ this.steerAfter = positiveInt(opts?.steerAfter, RESEARCH_STEER_AFTER);
130
+ this.stopAfter = Math.max(positiveInt(opts?.stopAfter, RESEARCH_STOP_AFTER), this.steerAfter + 1);
131
+ this.totalCap = positiveInt(opts?.totalCap, RESEARCH_TOTAL_CAP);
132
+ }
133
+ get webResearchCount() {
134
+ return this.webTotal;
135
+ }
136
+ get researchCapped() {
137
+ return this.capped;
138
+ }
139
+ get producedArtifact() {
140
+ return this.delivered;
141
+ }
142
+ /** Research rounds in a row that returned no new sources. */
143
+ get unproductiveStreak() {
144
+ return this.unproductiveRounds;
145
+ }
146
+ /** Record a tool call. Returns true if the run should halt (exact identity loop). */
26
147
  record(toolName, input) {
27
- const call = { toolName, input: serializeInput(input) };
28
- this.recentCalls.push(call);
29
- const window = this.recentCalls.slice(-THRESHOLD);
30
- const allSame = window.length === THRESHOLD &&
31
- window.every((c) => c.toolName === call.toolName && c.input === call.input);
32
- // Keep only the most recent THRESHOLD-1 calls to bound memory
33
- this.recentCalls = window.slice(-(THRESHOLD - 1));
34
- if (allSame) {
35
- this.recentCalls = [];
36
- return true;
37
- }
38
- return false;
148
+ return this.observe(toolName, input) === "halt";
149
+ }
150
+ /** A tool call is starting. */
151
+ observe(toolName, input) {
152
+ const key = `${toolName}\u0000${identityPayload(toolName, input)}`;
153
+ if (this.lastCall.key === key)
154
+ this.lastCall.count++;
155
+ else
156
+ this.lastCall = { key, count: 1 };
157
+ const total = (this.callCounts.get(key) ?? 0) + 1;
158
+ this.callCounts.set(key, total);
159
+ if (this.lastCall.count >= EXACT_LOOP_THRESHOLD || total >= REPEAT_TOTAL_THRESHOLD) {
160
+ this.lastCall = { key: "", count: 0 };
161
+ this.callCounts.delete(key);
162
+ return "halt";
163
+ }
164
+ if (WEB_RESEARCH_TOOLS.has(toolName)) {
165
+ this.webTotal++;
166
+ if (this.inFlight === 0)
167
+ this.batchProductive = false;
168
+ this.inFlight++;
169
+ if (!this.capped && this.webTotal >= this.totalCap) {
170
+ this.markCapped();
171
+ return "cap";
172
+ }
173
+ return "ok";
174
+ }
175
+ if (ARTIFACT_TOOLS.has(toolName)) {
176
+ // Real progress: forgive the research history so far.
177
+ this.delivered = true;
178
+ this.unproductiveRounds = 0;
179
+ }
180
+ // Other tools neither help nor hurt the research budget.
181
+ return "ok";
182
+ }
183
+ /**
184
+ * A tool result came back. Research productivity is judged here, once the
185
+ * whole parallel batch has landed, so four fresh searches in one step are not
186
+ * mistaken for a loop.
187
+ */
188
+ observeResult(toolName, output) {
189
+ if (!WEB_RESEARCH_TOOLS.has(toolName))
190
+ return "ok";
191
+ const text = typeof output === "string" ? output : safeString(output);
192
+ if (!RESULT_ERROR.test(text.trim())) {
193
+ const { urls, hosts } = extractSources(text);
194
+ let fresh = 0;
195
+ for (const url of urls) {
196
+ if (!this.seenUrls.has(url)) {
197
+ this.seenUrls.add(url);
198
+ fresh++;
199
+ }
200
+ }
201
+ for (const host of hosts) {
202
+ if (!this.seenHosts.has(host)) {
203
+ this.seenHosts.add(host);
204
+ fresh++;
205
+ }
206
+ }
207
+ if (fresh > 0)
208
+ this.batchProductive = true;
209
+ }
210
+ if (this.inFlight > 0)
211
+ this.inFlight--;
212
+ if (this.inFlight > 0)
213
+ return "ok";
214
+ if (this.batchProductive) {
215
+ this.unproductiveRounds = 0;
216
+ return "ok";
217
+ }
218
+ this.unproductiveRounds++;
219
+ if (this.capped)
220
+ return "ok";
221
+ if (this.unproductiveRounds >= this.stopAfter) {
222
+ this.markCapped();
223
+ return "cap";
224
+ }
225
+ if (!this.steered && this.unproductiveRounds >= this.steerAfter) {
226
+ this.steered = true;
227
+ return "steer";
228
+ }
229
+ return "ok";
230
+ }
231
+ markCapped() {
232
+ this.capped = true;
233
+ this.steered = true;
234
+ }
235
+ promptHint() {
236
+ if (this.capped) {
237
+ return [
238
+ "## Research budget",
239
+ "search_web and web_fetch have been removed for the rest of this turn.",
240
+ "Produce the requested deliverable from information already in the conversation.",
241
+ ].join("\n");
242
+ }
243
+ if (this.webTotal < 4)
244
+ return "";
245
+ const lines = [
246
+ "## Research budget",
247
+ `Research calls this turn: ${this.webTotal}/${this.totalCap}; rounds without new sources: ${this.unproductiveRounds}/${this.stopAfter}.`,
248
+ ];
249
+ if (this.steered || this.unproductiveRounds >= this.steerAfter) {
250
+ lines.push("Recent searches added nothing new. Stop searching and produce the requested deliverable; mark uncertain fields instead of looking for more.");
251
+ }
252
+ else {
253
+ lines.push("Keep queries targeted: fetch the best sources, then write the output.");
254
+ }
255
+ return lines.join("\n");
39
256
  }
40
257
  reset() {
41
- this.recentCalls = [];
258
+ this.lastCall = { key: "", count: 0 };
259
+ this.callCounts.clear();
260
+ this.webTotal = 0;
261
+ this.inFlight = 0;
262
+ this.batchProductive = false;
263
+ this.unproductiveRounds = 0;
264
+ this.steered = false;
265
+ this.capped = false;
266
+ this.delivered = false;
267
+ this.seenUrls.clear();
268
+ this.seenHosts.clear();
269
+ }
270
+ }
271
+ function safeString(value) {
272
+ try {
273
+ return JSON.stringify(value) ?? String(value);
274
+ }
275
+ catch {
276
+ return String(value);
42
277
  }
43
278
  }
@@ -0,0 +1,152 @@
1
+ import { log } from "./logger.js";
2
+ /**
3
+ * fetch wrapper for model requests.
4
+ *
5
+ * Providers can stall in two ways that a plain fetch never surfaces:
6
+ * 1. the response never starts (no headers / first byte),
7
+ * 2. the SSE stream opens and then goes quiet forever.
8
+ * Both would hang the agent loop indefinitely, so each has its own timer. On
9
+ * timeout the request is aborted with a marked error the loop recognises as a
10
+ * transient provider failure (see isProviderStallError).
11
+ *
12
+ * The wrapper also carries optional request/response tracing (MIN_AGENT_TRACE),
13
+ * which is the only way to see what a misbehaving gateway actually returned.
14
+ */
15
+ /** Marker embedded in stall errors so the agent loop can classify them. */
16
+ export const PROVIDER_STALL_MARKER = "min-agent: provider stalled";
17
+ export const DEFAULT_FIRST_BYTE_TIMEOUT_MS = 180_000;
18
+ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000;
19
+ function stallError(kind, ms) {
20
+ const err = new Error(`${PROVIDER_STALL_MARKER}: no ${kind} for ${ms}ms`);
21
+ err.name = "ProviderStallError";
22
+ return err;
23
+ }
24
+ /** Walk the error chain so a stall marker survives provider SDK wrapping. */
25
+ export function describeError(err, depth = 4) {
26
+ const parts = [];
27
+ let current = err;
28
+ for (let i = 0; i < depth && current != null; i++) {
29
+ if (current instanceof Error) {
30
+ parts.push(current.message);
31
+ current = current.cause;
32
+ continue;
33
+ }
34
+ parts.push(String(current));
35
+ break;
36
+ }
37
+ return parts.join(" | ");
38
+ }
39
+ /** True when the failure is a provider stall we injected (safe to retry). */
40
+ export function isProviderStallError(err) {
41
+ return describeError(err).includes(PROVIDER_STALL_MARKER);
42
+ }
43
+ function traceRequestLine(url, init) {
44
+ const body = typeof init?.body === "string" ? init.body : "";
45
+ let summary = `bytes=${body.length}`;
46
+ try {
47
+ const parsed = JSON.parse(body || "{}");
48
+ const tools = parsed.tools?.map((t) => t.function?.name ?? "?") ?? [];
49
+ summary += ` model=${parsed.model ?? "?"} messages=${parsed.messages?.length ?? 0} tools=${tools.length} stream=${parsed.stream ?? false}`;
50
+ }
51
+ catch {
52
+ /* not JSON — bytes only */
53
+ }
54
+ return `trace request ${url} ${summary}`;
55
+ }
56
+ /**
57
+ * Wrap a response body so the idle timer resets on every chunk and fires when
58
+ * the provider goes quiet mid-stream.
59
+ */
60
+ function guardBody(response, idleTimeoutMs, abort) {
61
+ if (!response.body || idleTimeoutMs <= 0)
62
+ return response;
63
+ const source = response.body;
64
+ const reader = source.getReader();
65
+ let timer;
66
+ const clear = () => {
67
+ if (timer)
68
+ clearTimeout(timer);
69
+ timer = undefined;
70
+ };
71
+ const guarded = new ReadableStream({
72
+ async pull(controller) {
73
+ const arm = () => {
74
+ clear();
75
+ timer = setTimeout(() => abort(stallError("stream", idleTimeoutMs)), idleTimeoutMs);
76
+ };
77
+ arm();
78
+ try {
79
+ const { done, value } = await reader.read();
80
+ clear();
81
+ if (done) {
82
+ controller.close();
83
+ return;
84
+ }
85
+ controller.enqueue(value);
86
+ }
87
+ catch (err) {
88
+ clear();
89
+ controller.error(err);
90
+ }
91
+ },
92
+ cancel(reason) {
93
+ clear();
94
+ return reader.cancel(reason);
95
+ },
96
+ });
97
+ return new Response(guarded, {
98
+ status: response.status,
99
+ statusText: response.statusText,
100
+ headers: response.headers,
101
+ });
102
+ }
103
+ export function createTimeoutFetch(options = {}) {
104
+ const firstByteTimeoutMs = options.firstByteTimeoutMs ?? DEFAULT_FIRST_BYTE_TIMEOUT_MS;
105
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
106
+ const doFetch = options.fetchImpl ?? fetch;
107
+ const now = options.now ?? Date.now;
108
+ const trace = options.trace ?? false;
109
+ return async (input, init) => {
110
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
111
+ const controller = new AbortController();
112
+ let stall;
113
+ const abortWith = (err) => {
114
+ stall = err;
115
+ log("warn", `${err.message} (${url})`);
116
+ controller.abort(err);
117
+ };
118
+ const external = init?.signal;
119
+ const forward = () => controller.abort(external?.reason);
120
+ if (external) {
121
+ if (external.aborted)
122
+ forward();
123
+ else
124
+ external.addEventListener("abort", forward, { once: true });
125
+ }
126
+ let firstByteTimer;
127
+ if (firstByteTimeoutMs > 0) {
128
+ firstByteTimer = setTimeout(() => abortWith(stallError("first byte", firstByteTimeoutMs)), firstByteTimeoutMs);
129
+ }
130
+ if (trace)
131
+ log("info", traceRequestLine(url, init));
132
+ const started = now();
133
+ try {
134
+ const response = await doFetch(input, { ...init, signal: controller.signal });
135
+ if (firstByteTimer)
136
+ clearTimeout(firstByteTimer);
137
+ if (trace)
138
+ log("info", `trace response ${url} status=${response.status} ttfb=${now() - started}ms`);
139
+ return guardBody(response, idleTimeoutMs, abortWith);
140
+ }
141
+ catch (err) {
142
+ if (firstByteTimer)
143
+ clearTimeout(firstByteTimer);
144
+ if (stall)
145
+ throw stall;
146
+ throw err;
147
+ }
148
+ finally {
149
+ external?.removeEventListener("abort", forward);
150
+ }
151
+ };
152
+ }
@@ -0,0 +1,60 @@
1
+ export const APPROVAL_TIMEOUT_MS = 300_000;
2
+ export function createApprovalGate(timeoutMs = APPROVAL_TIMEOUT_MS) {
3
+ const pending = new Map();
4
+ const clear = (id) => {
5
+ const item = pending.get(id);
6
+ if (!item)
7
+ return undefined;
8
+ clearTimeout(item.timer);
9
+ pending.delete(id);
10
+ return item;
11
+ };
12
+ return {
13
+ waitConfirm(id) {
14
+ return new Promise((resolve) => {
15
+ const timer = setTimeout(() => {
16
+ pending.delete(id);
17
+ resolve(false);
18
+ }, timeoutMs);
19
+ pending.set(id, { kind: "confirm", resolve, timer });
20
+ });
21
+ },
22
+ waitQuestion(id) {
23
+ return new Promise((resolve) => {
24
+ const timer = setTimeout(() => {
25
+ pending.delete(id);
26
+ resolve(null);
27
+ }, timeoutMs);
28
+ pending.set(id, { kind: "question", resolve, timer });
29
+ });
30
+ },
31
+ resolveConfirm(id, accepted) {
32
+ const item = pending.get(id);
33
+ if (!item || item.kind !== "confirm")
34
+ return false;
35
+ clear(id);
36
+ item.resolve(accepted);
37
+ return true;
38
+ },
39
+ resolveQuestion(id, answer) {
40
+ const item = pending.get(id);
41
+ if (!item || item.kind !== "question")
42
+ return false;
43
+ clear(id);
44
+ item.resolve(answer);
45
+ return true;
46
+ },
47
+ cancel(id) {
48
+ const item = clear(id);
49
+ if (!item)
50
+ return;
51
+ if (item.kind === "confirm")
52
+ item.resolve(false);
53
+ else
54
+ item.resolve(null);
55
+ },
56
+ has(id) {
57
+ return pending.has(id);
58
+ },
59
+ };
60
+ }
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
1
2
  import { readFileSync, existsSync } from "fs";
2
3
  import path from "path";
3
4
  import os from "os";
@@ -40,6 +41,9 @@ export class InstructionTracker {
40
41
  markLoaded(filepath) {
41
42
  this.loaded.add(path.resolve(filepath));
42
43
  }
44
+ reset() {
45
+ this.loaded.clear();
46
+ }
43
47
  /**
44
48
  * Context-aware: when a file is read, walk up from its directory
45
49
  * looking for AGENTS.md/RULES.md that haven't been loaded yet.
@@ -68,6 +72,23 @@ export class InstructionTracker {
68
72
  return results;
69
73
  }
70
74
  }
75
+ const instructionStore = new AsyncLocalStorage();
76
+ const fallbackInstructionTracker = new InstructionTracker();
77
+ /** Per-run tracker so concurrent HTTP chats do not share loaded-file state. */
78
+ export async function runWithInstructionTracker(fn) {
79
+ return instructionStore.run(new InstructionTracker(), fn);
80
+ }
81
+ export function getActiveInstructionTracker() {
82
+ return instructionStore.getStore() ?? fallbackInstructionTracker;
83
+ }
84
+ /** After compaction, nearby AGENTS.md must be allowed to re-inject. */
85
+ export function resetActiveInstructionTracker() {
86
+ const current = instructionStore.getStore();
87
+ if (current)
88
+ current.reset();
89
+ else
90
+ fallbackInstructionTracker.reset();
91
+ }
71
92
  export function findProjectInstructions() {
72
93
  const results = [];
73
94
  const root = path.parse(process.cwd()).root;
package/dist/logger.js CHANGED
@@ -34,13 +34,42 @@ function writeLog(level, msg) {
34
34
  try {
35
35
  const now = new Date();
36
36
  const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
37
- writeFileSync(logFilePath(now), `[${time}] [${level}] ${scrubSecrets(msg)}\n`, { flag: "a" });
37
+ writeFileSync(logFilePath(now), `[${time}] [${level}]${runTag()} ${scrubSecrets(msg)}\n`, { flag: "a" });
38
38
  }
39
39
  catch { }
40
40
  }
41
+ /**
42
+ * Run context stamped onto every log line, so interleaved turns (and the passes
43
+ * inside one turn) can be told apart when reading the file afterwards.
44
+ */
45
+ let runContext = null;
46
+ function runTag() {
47
+ if (!runContext)
48
+ return "";
49
+ return ` [run=${runContext.runId} pass=${runContext.pass}]`;
50
+ }
51
+ /** Start a new run scope and return its id. */
52
+ export function startRunLog() {
53
+ const runId = Math.random().toString(36).slice(2, 8);
54
+ runContext = { runId, pass: 0 };
55
+ return runId;
56
+ }
57
+ /** Mark the beginning of a model pass inside the current run. */
58
+ export function nextRunPass() {
59
+ if (!runContext)
60
+ startRunLog();
61
+ runContext.pass++;
62
+ return runContext.pass;
63
+ }
64
+ export function endRunLog() {
65
+ runContext = null;
66
+ }
67
+ export function currentRunId() {
68
+ return runContext?.runId ?? null;
69
+ }
41
70
  const SECRET_PATTERNS = [
42
- /\b(Bearer\s+)[A-Za-z0-9._~+/\-]{8,}/g,
43
- /\b(api[_-]?key|apikey|token|secret|password|passwd)\s*[:=]\s*["']?[A-Za-z0-9._~+/\-]{6,}["']?/gi,
71
+ /\b(Bearer\s+)[A-Za-z0-9._~+/-]{8,}/g,
72
+ /\b(api[_-]?key|apikey|token|secret|password|passwd)\s*[:=]\s*["']?[A-Za-z0-9._~+/-]{6,}["']?/gi,
44
73
  /\b(sk-[A-Za-z0-9_-]{6,})/g,
45
74
  /\b(gh[pousr]_[A-Za-z0-9]{20,})/g,
46
75
  ];
@@ -55,7 +84,7 @@ export function log(level, msg) {
55
84
  writeLog(level, msg);
56
85
  }
57
86
  function summarize(v, max = 200) {
58
- const s = typeof v === "string" ? v : JSON.stringify(v) ?? String(v);
87
+ const s = typeof v === "string" ? v : (JSON.stringify(v) ?? String(v));
59
88
  return s.length > max ? s.slice(0, max) + "…" : s;
60
89
  }
61
90
  export function logToolCall(name, input) {