agen-vektor 0.3.18 → 0.3.20

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.
@@ -238,22 +238,53 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
238
238
  callbacks.onStatus?.(iterations === 1 ? 'Thinking' : 'Thinking…');
239
239
  let result;
240
240
  try {
241
- result = await (0, provider_1.withRetry)(doCall, {
242
- retries: config.maxRetries,
243
- signal,
244
- onRetry: (attempt, err, waitMs) => {
245
- // Surface the silent retry loop: without this the user stares at a
246
- // frozen spinner for 1s+2s+4s while the gateway flakes.
247
- // friendlyApiError keeps provider bodies (JSON, stack-ish text) out
248
- // of the status bar — the raw dump leaked internals and pushed the
249
- // timer/mode labels off the row.
250
- callbacks.onStatus?.(`Retrying ${attempt}/${config.maxRetries} — ${(0, provider_1.friendlyApiError)(err)}, waiting ${Math.round(waitMs / 1000)}s`);
251
- // A partially-streamed reply from the FAILED attempt must not leak
252
- // into the UI or the next attempt's reasoning accumulator.
253
- lastReasoning = '';
254
- callbacks.onRetryReset?.();
255
- },
256
- });
241
+ // ANTI-COOLDOWN UX (2026-09-09): a 429 from the shared free gateway is
242
+ // absorbed INVISIBLY. withRetry deliberately does not ladder-retry 429;
243
+ // this grace loop owns rate-limit pacing: it honors the gateway's
244
+ // retryAfter (capped), waits quietly, and retries — the user only ever
245
+ // sees the status line, never an error, as long as the total budget
246
+ // (~120s) lasts. Only a persistently rate-limited key beyond that
247
+ // budget surfaces the calm "Provider busy" message. resultHistory
248
+ // covers the FIRST 429 round-trip too: a failure after a success is a
249
+ // transient gateway flap, not a dead shared key.
250
+ const graceDeadline = Date.now() + 120_000;
251
+ for (;;) {
252
+ try {
253
+ result = await (0, provider_1.withRetry)(doCall, {
254
+ retries: config.maxRetries,
255
+ signal,
256
+ onRetry: (attempt, err, waitMs) => {
257
+ // Surface the silent retry loop: without this the user stares at a
258
+ // frozen spinner for 1s+2s+4s while the gateway flakes.
259
+ // friendlyApiError keeps provider bodies (JSON, stack-ish text) out
260
+ // of the status bar — the raw dump leaked internals and pushed the
261
+ // timer/mode labels off the row.
262
+ callbacks.onStatus?.(`Retrying ${attempt}/${config.maxRetries} — ${(0, provider_1.friendlyApiError)(err)}, waiting ${Math.round(waitMs / 1000)}s`);
263
+ // A partially-streamed reply from the FAILED attempt must not leak
264
+ // into the UI or the next attempt's reasoning accumulator.
265
+ lastReasoning = '';
266
+ callbacks.onRetryReset?.();
267
+ },
268
+ });
269
+ break;
270
+ }
271
+ catch (err) {
272
+ if (err instanceof provider_1.ProviderError &&
273
+ err.status === 429 &&
274
+ !signal?.aborted &&
275
+ Date.now() < graceDeadline) {
276
+ const waitMs = Math.min(Math.max(err.retryAfterMs ?? 30_000, 5_000), Math.max(graceDeadline - Date.now(), 1_000));
277
+ callbacks.onStatus?.(`Provider busy — retrying in ${Math.round(waitMs / 1000)}s`);
278
+ lastReasoning = '';
279
+ callbacks.onRetryReset?.();
280
+ await new Promise((r) => setTimeout(r, waitMs));
281
+ if (signal?.aborted)
282
+ throw new Error('aborted');
283
+ continue;
284
+ }
285
+ throw err;
286
+ }
287
+ }
257
288
  }
258
289
  catch (err) {
259
290
  if (err.name === 'AbortError' || err.message === 'aborted') {
@@ -270,9 +301,9 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
270
301
  }
271
302
  if (err instanceof provider_1.ProviderError && err.status === 429) {
272
303
  callbacks.onError?.(new Error('Rate limited (429) by the provider.'));
273
- callbacks.onStatus?.('Rate limited');
304
+ callbacks.onStatus?.('Provider busy');
274
305
  stopped = true;
275
- finalContent = '! Rate limited by the provider. Wait a moment and try again.';
306
+ finalContent = '! Provider busy — automatically retried for ~2 minutes. Try sending your message again.';
276
307
  break;
277
308
  }
278
309
  callbacks.onError?.(err);
@@ -132,7 +132,7 @@ class AnthropicProvider {
132
132
  if (!res.ok) {
133
133
  const detail = safeJson(text);
134
134
  const errObj = (detail && detail.error);
135
- throw new provider_1.ProviderError(errObj?.message || `HTTP ${res.status}: ${text.slice(0, 300)}`, res.status, res.status >= 500 || res.status === 429);
135
+ throw new provider_1.ProviderError(errObj?.message || `HTTP ${res.status}: ${text.slice(0, 300)}`, res.status, res.status >= 500 || res.status === 429, (0, provider_1.parseRetryAfterMs)(res, text));
136
136
  }
137
137
  return safeJson(text) || {};
138
138
  }
@@ -305,7 +305,7 @@ class OpenAICompatProvider {
305
305
  const detail = safeJson(text);
306
306
  const errObj = (detail && detail.error);
307
307
  const msg = errObj?.message || text.slice(0, 300);
308
- throw new provider_1.ProviderError(msg, res.status, res.status >= 500 || res.status === 429);
308
+ throw new provider_1.ProviderError(msg, res.status, res.status >= 500 || res.status === 429, (0, provider_1.parseRetryAfterMs)(res, text));
309
309
  }
310
310
  return safeJson(text) || {};
311
311
  }
@@ -376,7 +376,7 @@ class OpenAICompatProvider {
376
376
  const text = await res.text().catch(() => '');
377
377
  const detail = safeJson(text);
378
378
  const errObj = (detail && detail.error);
379
- throw new provider_1.ProviderError(errObj?.message || `HTTP ${res.status}: ${text.slice(0, 200)}`, res.status, res.status >= 500 || res.status === 429);
379
+ throw new provider_1.ProviderError(errObj?.message || `HTTP ${res.status}: ${text.slice(0, 200)}`, res.status, res.status >= 500 || res.status === 429, (0, provider_1.parseRetryAfterMs)(res, text));
380
380
  }
381
381
  const reader = res.body.getReader();
382
382
  const decoder = new TextDecoder();
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ProviderError = void 0;
4
4
  exports.mergeStreamDelta = mergeStreamDelta;
5
5
  exports.friendlyApiError = friendlyApiError;
6
+ exports.parseRetryAfterMs = parseRetryAfterMs;
6
7
  exports.withRetry = withRetry;
7
8
  /**
8
9
  * Idempotent merge of a streamed delta into the reply content so far.
@@ -43,7 +44,11 @@ function mergeStreamDelta(existing, delta) {
43
44
  class ProviderError extends Error {
44
45
  status;
45
46
  retryable;
46
- constructor(message, status, retryable = false) {
47
+ retryAfterMs;
48
+ constructor(message, status, retryable = false,
49
+ /** Server-advised wait before retrying (ms) — from Retry-After / retryAfter.
50
+ * withRetry uses it instead of the plain 1s/2s/4s ladder for 429s. */
51
+ retryAfterMs) {
47
52
  super(message
48
53
  // Upstream error bodies are often PRETTY-PRINTED JSON — the raw
49
54
  // text carries real newlines (e.g. `HTTP 429: {\n "error": …}`).
@@ -57,6 +62,7 @@ class ProviderError extends Error {
57
62
  .trim());
58
63
  this.status = status;
59
64
  this.retryable = retryable;
65
+ this.retryAfterMs = retryAfterMs;
60
66
  this.name = 'ProviderError';
61
67
  }
62
68
  }
@@ -107,6 +113,37 @@ function friendlyApiError(err) {
107
113
  const text = cleaned || noJson || raw;
108
114
  return text.length > 64 ? `${text.slice(0, 63)}…` : text;
109
115
  }
116
+ /**
117
+ * Server-advised retry delay for 429s, in ms (0 when absent). Sources, in
118
+ * order: the standard `Retry-After` header (delta-seconds or HTTP-date) and
119
+ * the VectorHead gateway's JSON body field `retryAfter` (seconds). Keeps
120
+ * client retries aligned with the gateway breaker instead of burning the
121
+ * 1s/2s/4s ladder against a still-parked shared key.
122
+ */
123
+ function parseRetryAfterMs(res, bodyText) {
124
+ const cap = 60_000;
125
+ const ra = res.headers.get('retry-after');
126
+ if (ra) {
127
+ const secs = Number(ra);
128
+ if (Number.isFinite(secs) && secs >= 0)
129
+ return Math.min(secs * 1000, cap);
130
+ const at = Date.parse(ra);
131
+ if (Number.isFinite(at))
132
+ return Math.max(0, Math.min(at - Date.now(), cap));
133
+ }
134
+ if (bodyText) {
135
+ try {
136
+ const j = JSON.parse(bodyText);
137
+ const secs = typeof j.retryAfter === 'number' ? j.retryAfter : Number(j.retryAfter);
138
+ if (Number.isFinite(secs) && secs >= 0)
139
+ return Math.min(secs * 1000, cap);
140
+ }
141
+ catch {
142
+ /* body not JSON — header-only */
143
+ }
144
+ }
145
+ return 0;
146
+ }
110
147
  /** Simple retry wrapper for provider calls. */
111
148
  async function withRetry(fn, { retries = 3, delayMs = 1000, signal,
112
149
  /** Called before each wait so the UI can show "retrying…" instead of a
@@ -135,6 +172,12 @@ onRetry, } = {}) {
135
172
  : true;
136
173
  if (!retryable || attempt > retries)
137
174
  throw err;
175
+ // 429 is NEVER retried by the plain ladder: the agent loop owns
176
+ // rate-limit pacing (grace retries honoring retryAfterMs). Ladder
177
+ // retries here would burn attempts against a parked shared key and
178
+ // delay the calm “Provider busy — retrying” UX.
179
+ if (err instanceof ProviderError && err.status === 429)
180
+ throw err;
138
181
  const wait = delayMs * Math.pow(2, attempt - 1);
139
182
  onRetry?.(attempt, err, wait);
140
183
  await new Promise((r) => setTimeout(r, wait));
@@ -7,39 +7,94 @@ exports.createShellTool = createShellTool;
7
7
  * Every command is classified by the command policy and gated by permissions.
8
8
  */
9
9
  const node_child_process_1 = require("node:child_process");
10
+ // After the shell itself exits, wait this long for the stdio pipes to drain
11
+ // (normal commands flush and close within milliseconds) before giving up.
12
+ // A command that ORPHANS a child holding the pipes (background job, server,
13
+ // watcher …) would otherwise keep the tool — and the whole agent loop —
14
+ // hanging until the grandchild dies ("sering nyangkut di shell", 2026-09-10).
15
+ const ORPHAN_GRACE_MS = 500;
10
16
  function runShell(command, cwd, opts = {}) {
11
17
  return new Promise((resolve) => {
12
18
  const timeoutMs = opts.timeoutMs || 120_000;
19
+ // Own process group (detached) → the timeout / orphan guard can kill the
20
+ // whole tree (shell AND grandchildren), same policy as background.ts.
13
21
  const child = (0, node_child_process_1.spawn)(command, {
14
22
  cwd,
15
23
  shell: '/bin/sh',
16
24
  env: { ...process.env, ...opts.env },
17
25
  stdio: ['ignore', 'pipe', 'pipe'],
26
+ detached: true,
18
27
  });
19
28
  let stdout = '';
20
29
  let stderr = '';
21
30
  let timedOut = false;
31
+ let settled = false;
32
+ let graceTimer = null;
33
+ const killTree = () => {
34
+ if (child.pid != null) {
35
+ try {
36
+ process.kill(-child.pid, 'SIGKILL'); // detached spawn ⇒ own process group
37
+ }
38
+ catch {
39
+ /* group already gone */
40
+ }
41
+ }
42
+ try {
43
+ child.kill('SIGKILL');
44
+ }
45
+ catch {
46
+ /* already gone */
47
+ }
48
+ };
49
+ const finish = (code) => {
50
+ if (settled)
51
+ return;
52
+ settled = true;
53
+ clearTimeout(timer);
54
+ if (graceTimer)
55
+ clearTimeout(graceTimer);
56
+ resolve({ stdout, stderr, exitCode: code, timedOut });
57
+ };
22
58
  const timer = setTimeout(() => {
23
59
  timedOut = true;
24
- child.kill('SIGKILL');
60
+ killTree(); // was: child.kill() only — orphaned children kept the pipe open and the promise hanging past the timeout
61
+ // 'exit' fires once the shell dies and resolves immediately.
25
62
  }, timeoutMs);
26
63
  child.stdout.on('data', (d) => {
27
64
  stdout += d.toString();
28
65
  if (stdout.length > 2_000_000)
29
- child.kill('SIGKILL');
66
+ killTree();
30
67
  });
31
68
  child.stderr.on('data', (d) => {
32
69
  stderr += d.toString();
33
70
  if (stderr.length > 2_000_000)
34
- child.kill('SIGKILL');
71
+ killTree();
72
+ });
73
+ // 'exit' = the shell itself died; 'close' = shell AND stdio pipes closed.
74
+ // Resolve on whichever happens first with a small grace window after
75
+ // 'exit': a normal command flushes and closes well inside the window, so
76
+ // full output still arrives — but an orphaned grandchild holding the
77
+ // pipes can no longer hang the tool past it.
78
+ child.on('exit', (code) => {
79
+ if (timedOut) {
80
+ finish(code);
81
+ return;
82
+ }
83
+ if (graceTimer)
84
+ return;
85
+ graceTimer = setTimeout(() => {
86
+ killTree(); // stop the orphan so its output stops too
87
+ finish(code);
88
+ }, ORPHAN_GRACE_MS);
35
89
  });
36
90
  child.on('close', (code) => {
37
- clearTimeout(timer);
38
- resolve({ stdout, stderr, exitCode: code, timedOut });
91
+ finish(code);
39
92
  });
40
93
  child.on('error', (e) => {
41
- clearTimeout(timer);
42
- resolve({ stdout, stderr: `spawn error: ${e.message}`, exitCode: -1, timedOut });
94
+ // set stderr BEFORE finish — the resolved object snapshots these strings
95
+ if (!stdout && !stderr)
96
+ stderr = `spawn error: ${e.message}`;
97
+ finish(-1);
43
98
  });
44
99
  });
45
100
  }
@@ -52,7 +107,7 @@ function createShellTool() {
52
107
  return {
53
108
  definition: {
54
109
  name: 'shell',
55
- description: 'Run a shell command in the project directory. The command is classified for safety; risky commands require permission. Use for building, testing, installing, git, or any command execution. Long output is truncated. For git commits prefer the dedicated `git` tool and follow its commit-attribution guidance.',
110
+ description: 'Run a shell command in the project directory. The command is classified for safety; risky commands require permission. Use for building, testing, installing, git, or any command execution. Long output is truncated. Commands that keep running (dev servers, watchers, polling loops) must use run_in_background instead — a foreground shell is killed after its timeout. For git commits prefer the dedicated `git` tool and follow its commit-attribution guidance.',
56
111
  parameters: {
57
112
  type: 'object',
58
113
  properties: {
package/dist/tui/app.js CHANGED
@@ -1804,11 +1804,12 @@ class App {
1804
1804
  // never shows gateway internals.
1805
1805
  let errorContent = `! ${(0, provider_1.friendlyApiError)(err)}`;
1806
1806
  if (exhausted && this.agent.config.provider === free_tier_1.FREE_PROVIDER_ID) {
1807
- // TIDAK ada kuota perangkat di tier free (dihapus dari gateway).
1808
- // Error quota di sini = cooldown key GATEWAY (akun bersama), bukan
1809
- // kesalahan user — jangan menuduh kuota habis.
1807
+ // ANTI-COOLDOWN UX (2026-09-09): loop sudah meng-absorpsi 429 dgn
1808
+ // grace retry ~2 menit — pesan ini hanya muncul kalau gateway benar-
1809
+ // benar masih ramai SETELAH itu. Copy-nya tenang & pasif (penerimaan,
1810
+ // bukan dinding instruksi BYOK); otomatis coba lagi adalah default.
1810
1811
  errorContent =
1811
- '! Key gateway VectorHead Free sedang cooldown sebentar (dipakai akun bersama) — coba lagi beberapa menit, atau ganti model via /model, atau pakai API key sendiri via /connect (BYOK).';
1812
+ '! Provider sedang ramai (akun gratis dipakai bersama). Percobaan berikutnya berjalan otomatis — tidak perlu mengatur apa pun.';
1812
1813
  }
1813
1814
  this.messages.push({ kind: 'error', content: errorContent, ts: Date.now() });
1814
1815
  this.scrollToLatest();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agen-vektor",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "VectorHead (agen-vektor) — AI Coding Agent CLI/TUI for Linux & Termux. Multi-provider, tool calling, session, permission system.",
5
5
  "type": "commonjs",
6
6
  "bin": {