@irythill/orq 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.
- package/README.md +48 -11
- package/README.pt-BR.md +40 -11
- package/dist/ci.js +239 -0
- package/dist/cli.js +7 -1
- package/dist/commands/agent.js +61 -6
- package/dist/commands/block.js +20 -3
- package/dist/commands/board.js +87 -8
- package/dist/commands/ci.js +48 -0
- package/dist/commands/claim.js +148 -12
- package/dist/commands/completion.js +3 -2
- package/dist/commands/gc.js +1 -1
- package/dist/commands/handoff.js +259 -0
- package/dist/commands/pick.js +18 -3
- package/dist/commands/quota.js +34 -9
- package/dist/commands/rounds.js +58 -0
- package/dist/config.js +4 -1
- package/dist/identity.js +55 -0
- package/dist/pick.js +106 -9
- package/dist/quota.js +83 -21
- package/dist/rounds.js +167 -0
- package/dist/testrun.js +18 -1
- package/package.json +1 -1
- package/skills/issue/SKILL.md +42 -5
- package/skills/orq-setup/SKILL.md +1 -1
- package/templates/workspace.CLAUDE.md +7 -0
package/dist/pick.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { m } from "./i18n.js";
|
|
2
|
+
import { humanReset } from "./quota.js";
|
|
2
3
|
import { legacyRole, resolveRole, vendorOf } from "./roles.js";
|
|
3
4
|
/** "<name> 5h=NN% week=NN% [note]" lines → QuotaMap (same reading as the old orq-pick). */
|
|
4
5
|
export function parseQuota(text) {
|
|
@@ -17,6 +18,45 @@ export function parseQuota(text) {
|
|
|
17
18
|
}
|
|
18
19
|
return out;
|
|
19
20
|
}
|
|
21
|
+
/** The same lines → each window's %, plus its reset time (epoch seconds) when the line carries one. */
|
|
22
|
+
export function parseWindows(text) {
|
|
23
|
+
const out = {};
|
|
24
|
+
const num = (v) => (v && /^\d+%$/.test(v) ? Number(v.slice(0, -1)) : undefined);
|
|
25
|
+
for (const l of text.split(/\r?\n/)) {
|
|
26
|
+
const r = /^(\S+) 5h=(\S+) week=(\S+)(?: reset5h=(\d+))?(?: resetweek=(\d+))?(?: span5h=(\d+))?(?: spanweek=(\d+))?/.exec(l.trim());
|
|
27
|
+
if (!r)
|
|
28
|
+
continue;
|
|
29
|
+
// "[login expired: run `agy models`; last reading, 5 min ago]" → the part before the first ";" (the fix to show)
|
|
30
|
+
const note = /\[([^\]]*)\]\s*$/.exec(l)?.[1].split(";")[0].trim();
|
|
31
|
+
out[r[1]] = { five: num(r[2]), week: num(r[3]), ...(r[4] ? { resetFive: Number(r[4]) } : {}),
|
|
32
|
+
...(r[5] ? { resetWeek: Number(r[5]) } : {}), ...(r[6] ? { spanFive: Number(r[6]) } : {}),
|
|
33
|
+
...(r[7] ? { spanWeek: Number(r[7]) } : {}), ...(note && EXPIRED.test(note) ? { expired: note } : note ? { note } : {}) };
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
// Both languages orq writes these notes in (the collectors' not-logged-in and expired-login texts).
|
|
38
|
+
/** agy's idle-token note (collector text, both languages). */
|
|
39
|
+
const IDLE = /token idle|token ocioso/i;
|
|
40
|
+
const EXPIRED = /login expired|login expirado|not logged in|no agy login|sem login/i;
|
|
41
|
+
/** Window lengths in seconds, unless the quota line publishes another (span5h/spanweek, minutes). */
|
|
42
|
+
const WINDOW_S = { five: 5 * 3600, week: 7 * 86400 };
|
|
43
|
+
/** Usage at the window's reset if the pace so far continues (used ÷ elapsed share); undefined when it can't be told:
|
|
44
|
+
* no figure or reset time, or under 10% of the window elapsed (too early to project). */
|
|
45
|
+
export function projected(pct, reset, size, now) {
|
|
46
|
+
if (pct === undefined || pct < 0 || reset === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
const left = reset - now;
|
|
49
|
+
if (left <= 0 || left > size)
|
|
50
|
+
return undefined;
|
|
51
|
+
const elapsed = (size - left) / size;
|
|
52
|
+
return elapsed < 0.1 ? undefined : pct / elapsed;
|
|
53
|
+
}
|
|
54
|
+
const FIVE = { simple: 90, medium: 85, heavy: 80 };
|
|
55
|
+
/** [5h limit, weekly limit] for a task level; an unknown level counts as medium. */
|
|
56
|
+
export function limitsFor(pol, level) {
|
|
57
|
+
const five = { ...FIVE, ...(pol.quota_5h ?? {}) };
|
|
58
|
+
return [Math.min(Number(five[level] ?? five.medium), Number(pol.quota_limit ?? 90)), Number(pol.quota_week ?? 95)];
|
|
59
|
+
}
|
|
20
60
|
export function modelFor(a, role, level) {
|
|
21
61
|
if (a.models === undefined)
|
|
22
62
|
return ["", undefined]; // no models configured: the CLI's default
|
|
@@ -35,13 +75,18 @@ export function pick(inp) {
|
|
|
35
75
|
const { cfg, role, level } = inp;
|
|
36
76
|
const agents = (cfg.agents ?? {});
|
|
37
77
|
const pol = (cfg.policy ?? {});
|
|
38
|
-
const
|
|
78
|
+
const [base5, baseWeek] = limitsFor(pol, level);
|
|
39
79
|
const w = weights(cfg);
|
|
80
|
+
const now = inp.now ?? Date.now() / 1000;
|
|
81
|
+
const penalty = Number(pol.pacing_penalty ?? 0.3);
|
|
40
82
|
const res = resolveRole(cfg, role);
|
|
41
83
|
const names = res.mode === "fixed" ? res.order : res.pool;
|
|
42
84
|
const notes = [];
|
|
43
85
|
const out = (n, why) => { notes.push(`${n}: ${why}`); };
|
|
44
86
|
const pass = [];
|
|
87
|
+
// Only with someone else to ask: a single agent must still be able to review.
|
|
88
|
+
const someoneElse = names.some((n) => n !== inp.implAgent && agents[n]);
|
|
89
|
+
const otherVendor = names.some((n) => n !== inp.implAgent && agents[n] && vendorOf(n, agents[n]) !== inp.implVendor);
|
|
45
90
|
for (const name of names) {
|
|
46
91
|
const a = agents[name];
|
|
47
92
|
if (!a) {
|
|
@@ -56,7 +101,11 @@ export function pick(inp) {
|
|
|
56
101
|
out(name, m("blocked (orq block -l)", "bloqueado (orq block -l)"));
|
|
57
102
|
continue;
|
|
58
103
|
}
|
|
59
|
-
if ((role === "review" || role === "opinion") &&
|
|
104
|
+
if ((role === "review" || role === "opinion") && name === inp.implAgent && someoneElse) {
|
|
105
|
+
out(name, m("it implemented this code", "foi quem implementou"));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if ((role === "review" || role === "opinion") && res.rule === "other-vendor" && inp.implVendor && otherVendor
|
|
60
109
|
&& vendorOf(name, a) === inp.implVendor) {
|
|
61
110
|
out(name, m("same vendor as the implementer", "mesmo fornecedor do implementador"));
|
|
62
111
|
continue;
|
|
@@ -66,22 +115,68 @@ export function pick(inp) {
|
|
|
66
115
|
out(name, m(`no model for ${role}/${level}`, `sem modelo para ${role}/${level}`));
|
|
67
116
|
continue;
|
|
68
117
|
}
|
|
69
|
-
// Quota: an agent with several accounts (claude)
|
|
118
|
+
// Quota: an agent with several accounts (claude) prefers one not projecting past the limit, then the least used.
|
|
70
119
|
const qn = a.quota ?? [];
|
|
120
|
+
const headroom = Math.max(0, Number(a.headroom ?? 0));
|
|
121
|
+
const limit5 = base5 - headroom, limitWeek = baseWeek - headroom;
|
|
71
122
|
const accs = qn.filter((n) => !inp.blocks.has(n)).map((n) => [n, n in inp.quota ? inp.quota[n] : -1]);
|
|
72
123
|
if (qn.length && !accs.length) {
|
|
73
124
|
out(name, m("all accounts blocked", "todas as contas bloqueadas"));
|
|
74
125
|
continue;
|
|
75
126
|
}
|
|
76
|
-
const
|
|
127
|
+
const fix = accs.map(([n]) => inp.windows?.[n]?.expired).filter((x) => Boolean(x));
|
|
128
|
+
if (accs.length && fix.length === accs.length) {
|
|
129
|
+
out(name, fix[0]);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// An idle agy token with nothing cached: the fix is one agy run, not a quota number.
|
|
133
|
+
const noRead = m("no reading yet: run `agy models` once", "sem leitura ainda: rode `agy models` uma vez");
|
|
134
|
+
const idleNoData = ([n, v]) => v === null && IDLE.test(inp.windows?.[n]?.note ?? "");
|
|
135
|
+
if (accs.length && accs.every(idleNoData)) {
|
|
136
|
+
out(name, noRead);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const under = ([n, v]) => {
|
|
140
|
+
const q = inp.windows?.[n];
|
|
141
|
+
return !q?.expired && v !== null && (q?.five ?? (q ? -1 : v)) < limit5 && (q?.week ?? (q ? -1 : v)) < limitWeek;
|
|
142
|
+
};
|
|
143
|
+
const usable = accs.filter(under);
|
|
77
144
|
if (accs.length && !usable.length) {
|
|
78
|
-
|
|
145
|
+
const detail = accs.map(([n, v]) => {
|
|
146
|
+
const q = inp.windows?.[n], vn = v ?? -1;
|
|
147
|
+
if (q?.expired)
|
|
148
|
+
return `${n}: ${q.expired}`;
|
|
149
|
+
if (idleNoData([n, v]))
|
|
150
|
+
return `${n}: ${noRead}`;
|
|
151
|
+
const five = q?.five ?? (q ? -1 : vn), week = q?.week ?? (q ? -1 : vn);
|
|
152
|
+
const fiveOver = five >= limit5, weekOver = week >= limitWeek;
|
|
153
|
+
// The account is usable again only once every over-limit window has reset: the latest of their reset times.
|
|
154
|
+
// If one of them has no reset time, that time can't be promised at all.
|
|
155
|
+
const known = [fiveOver ? q?.resetFive : undefined, weekOver ? q?.resetWeek : undefined]
|
|
156
|
+
.filter((t) => t !== undefined);
|
|
157
|
+
const allKnown = (fiveOver || weekOver) && (!fiveOver || q?.resetFive !== undefined) && (!weekOver || q?.resetWeek !== undefined);
|
|
158
|
+
return `${n}=${v}` + (q?.note ? ` (${q.note})` : "") + (allKnown ? ` (${m("back", "volta")} ${humanReset(Math.max(...known))})` : "");
|
|
159
|
+
}).join(", ");
|
|
160
|
+
out(name, `${m("quota", "cota")} >= 5h ${limit5}% / ${m("week", "semana")} ${limitWeek}% ${m("or no data", "ou sem dados")}`
|
|
161
|
+
+ (headroom ? ` (headroom ${headroom})` : "") + ` (${detail})`);
|
|
79
162
|
continue;
|
|
80
163
|
}
|
|
81
|
-
const
|
|
164
|
+
const paceOf = (n) => {
|
|
165
|
+
const q = inp.windows?.[n];
|
|
166
|
+
const p5 = projected(q?.five, q?.resetFive, (q?.spanFive ? q.spanFive * 60 : WINDOW_S.five), now);
|
|
167
|
+
const pw = projected(q?.week, q?.resetWeek, (q?.spanWeek ? q.spanWeek * 60 : WINDOW_S.week), now);
|
|
168
|
+
const paces = [p5, pw].filter((x) => x !== undefined);
|
|
169
|
+
// Hot: past the same per-window limit the filter uses (5h by level, week), headroom included, when pacing is on.
|
|
170
|
+
return { pace: paces.length ? Math.max(...paces) : undefined,
|
|
171
|
+
hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
|
|
172
|
+
};
|
|
173
|
+
const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
|
|
174
|
+
.sort((x, y) => Number(x.hot) - Number(y.hot) || x.v - y.v);
|
|
175
|
+
const chosen = ranked[0];
|
|
176
|
+
const acct = qn.length > 1 ? chosen?.n : undefined;
|
|
82
177
|
const statRole = role === "fix" || role === "worker" ? "impl" : role;
|
|
83
178
|
const st = inp.stats.find((r) => r.role === statRole && r.agent === name && r.model === (mf[0].split("/").pop() || "default"));
|
|
84
|
-
pass.push({ agent: name, model: mf[0], effort: mf[1], acct, a, st });
|
|
179
|
+
pass.push({ agent: name, model: mf[0], effort: mf[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
|
|
85
180
|
if (res.mode === "fixed")
|
|
86
181
|
break; // the first that passes wins
|
|
87
182
|
}
|
|
@@ -102,11 +197,13 @@ export function pick(inp) {
|
|
|
102
197
|
const t = few(c.st) || !c.st?.new || !toks.length ? prior : Math.min(...toks) / c.st.new;
|
|
103
198
|
const s = few(c.st) || !c.st?.min || !mins.length ? prior : Math.min(...mins) / Math.max(c.st.min, 0.1);
|
|
104
199
|
const used = inp.recent.filter((p) => p.agent === c.agent).length;
|
|
200
|
+
const hot = Boolean(c.hot) && penalty > 0;
|
|
105
201
|
const score = w.quality * d + w.tokens * t + w.speed * s - w.rotation * used
|
|
106
|
-
- (reserve ? w.reserve : 0) + (few(c.st) ? w.explore : 0);
|
|
202
|
+
- (reserve ? w.reserve : 0) + (few(c.st) ? w.explore : 0) - (hot ? penalty : 0);
|
|
107
203
|
const why = `${m("delivery", "entrega")}=${d.toFixed(2)} ${m("economy", "economia")}=${t.toFixed(2)} `
|
|
108
204
|
+ `${m("speed", "rapidez")}=${s.toFixed(2)} ${m("rotation", "rodízio")}=-${used} `
|
|
109
|
-
+ (reserve ? m("reserve ", "reserva ") : "")
|
|
205
|
+
+ (reserve ? m("reserve ", "reserva ") : "")
|
|
206
|
+
+ (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "") + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
|
|
110
207
|
return { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score, why };
|
|
111
208
|
}).sort((x, y) => y.score - x.score);
|
|
112
209
|
return { mode: "auto", best: cands[0], cands, notes };
|
package/dist/quota.js
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
// false left out of the list entirely
|
|
11
11
|
// "none" no collector: never skipped for quota; use `orq block` when it runs out
|
|
12
12
|
// { command = "..." } any command printing lines "<name> 5h=NN% week=NN% [note]"
|
|
13
|
-
// Output line (read by orq-pick and the board): "<name> 5h=NN% week=NN% [note]".
|
|
13
|
+
// Output line (read by orq-pick and the board): "<name> 5h=NN% week=NN% [reset5h=epoch] [resetweek=epoch] [note]".
|
|
14
14
|
// - = the plan has no such window (not limiting); ? = no data (expired login, collector failed).
|
|
15
|
+
// reset5h/resetweek (epoch seconds, optional) sit between the windows and the note: whichever window's reset time
|
|
16
|
+
// the collector could read, when it's known.
|
|
15
17
|
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
16
19
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
17
20
|
import { homedir, userInfo } from "node:os";
|
|
18
21
|
import { join } from "node:path";
|
|
@@ -22,21 +25,45 @@ import { readSecret } from "./keyring.js";
|
|
|
22
25
|
import { stateDir } from "./paths.js";
|
|
23
26
|
import { m } from "./i18n.js";
|
|
24
27
|
const show = (p) => (typeof p === "number" ? `${Math.floor(p)}%` : p);
|
|
25
|
-
|
|
28
|
+
// reset5h/resetweek (epoch seconds) sit between the windows and the note: parseQuota/parseWindows only look at the
|
|
29
|
+
// first three words, so old cached lines (no reset fields) and lines with them both parse the same way.
|
|
30
|
+
export const format = (q) => `${q.name} 5h=${show(q.five)} week=${show(q.week)}`
|
|
31
|
+
+ (q.reset5h !== undefined ? ` reset5h=${q.reset5h}` : "") + (q.resetWeek !== undefined ? ` resetweek=${q.resetWeek}` : "")
|
|
32
|
+
+ (q.span5h !== undefined ? ` span5h=${q.span5h}` : "") + (q.spanWeek !== undefined ? ` spanweek=${q.spanWeek}` : "")
|
|
33
|
+
+ (q.note ? ` [${q.note}]` : "");
|
|
26
34
|
const noData = (name, note) => ({ name, five: "?", week: "?", note });
|
|
35
|
+
const WD = [["Sun", "dom"], ["Mon", "seg"], ["Tue", "ter"], ["Wed", "qua"], ["Thu", "qui"], ["Fri", "sex"], ["Sat", "sáb"]];
|
|
36
|
+
/** A reset epoch (seconds) as "14:30" when it falls today, else weekday + time. */
|
|
37
|
+
export function humanReset(epochSec, now = Date.now()) {
|
|
38
|
+
const d = new Date(epochSec * 1000), n = new Date(now);
|
|
39
|
+
const hhmm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
40
|
+
if (d.toDateString() === n.toDateString())
|
|
41
|
+
return hhmm;
|
|
42
|
+
const [en, pt] = WD[d.getDay()];
|
|
43
|
+
return `${m(en, pt)} ${hhmm}`;
|
|
44
|
+
}
|
|
27
45
|
const KEEP_MS = 5 * 3600_000;
|
|
28
46
|
const cacheFile = (name) => join(stateDir(), "quota", `${name}.json`);
|
|
29
|
-
|
|
47
|
+
const resetOf = (w) => {
|
|
48
|
+
const t = w.resetsAt ? Date.parse(w.resetsAt) : NaN;
|
|
49
|
+
return Number.isNaN(t) ? undefined : Math.floor(t / 1000);
|
|
50
|
+
};
|
|
51
|
+
// `acct` identifies the login behind a reading (antigravity: one login per machine, swapped by the user): a cache
|
|
52
|
+
// written under another login must never stand in for this one.
|
|
53
|
+
function remember(name, five, week, acct) {
|
|
30
54
|
try {
|
|
31
|
-
writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now() }));
|
|
55
|
+
writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now(), ...(acct ? { acct } : {}) }));
|
|
32
56
|
}
|
|
33
57
|
catch { /* best effort */ }
|
|
34
|
-
|
|
58
|
+
const r5 = resetOf(five), rw = resetOf(week);
|
|
59
|
+
return { name, five: five.pct, week: week.pct, ...(r5 !== undefined ? { reset5h: r5 } : {}), ...(rw !== undefined ? { resetWeek: rw } : {}) };
|
|
35
60
|
}
|
|
36
61
|
/** The cached reading (its age in the note), window by window; nothing usable → the "no data" line. */
|
|
37
|
-
export function fromCache(name, why, now = Date.now()) {
|
|
62
|
+
export function fromCache(name, why, now = Date.now(), acct) {
|
|
38
63
|
try {
|
|
39
64
|
const c = JSON.parse(readFileSync(cacheFile(name), "utf8"));
|
|
65
|
+
if (acct !== undefined && c.acct !== acct)
|
|
66
|
+
return noData(name, why); // another login's reading
|
|
40
67
|
const still = (w) => {
|
|
41
68
|
if (w.resetsAt) {
|
|
42
69
|
const t = Date.parse(w.resetsAt);
|
|
@@ -48,7 +75,10 @@ export function fromCache(name, why, now = Date.now()) {
|
|
|
48
75
|
const five = still(c.five), week = still(c.week);
|
|
49
76
|
if (five !== undefined && week !== undefined) {
|
|
50
77
|
const min = Math.round((now - c.at) / 60_000);
|
|
51
|
-
|
|
78
|
+
const r5 = resetOf(c.five), rw = resetOf(c.week);
|
|
79
|
+
return { name, five, week, ...(r5 !== undefined && r5 * 1000 > now ? { reset5h: r5 } : {}),
|
|
80
|
+
...(rw !== undefined && rw * 1000 > now ? { resetWeek: rw } : {}),
|
|
81
|
+
note: `${why}; ` + m(`last reading, ${min} min ago`, `última leitura, há ${min} min`) };
|
|
52
82
|
}
|
|
53
83
|
}
|
|
54
84
|
catch { /* no cache */ }
|
|
@@ -143,10 +173,14 @@ export function codexFromSession(text, nowSec) {
|
|
|
143
173
|
return undefined;
|
|
144
174
|
const ws = [last.primary, last.secondary].filter((w) => !!w);
|
|
145
175
|
const pct = (w) => !w ? "-" : (w.resets_at ?? 0) < nowSec ? 0 : Math.floor(w.used_percent ?? 0);
|
|
176
|
+
const reset = (w) => w?.resets_at !== undefined && w.resets_at >= nowSec ? w.resets_at : undefined;
|
|
146
177
|
const short = ws.find((x) => (x.window_minutes ?? 0) <= 360);
|
|
147
178
|
const long = ws.filter((x) => (x.window_minutes ?? 0) > 360).sort((a, b) => b.window_minutes - a.window_minutes)[0];
|
|
148
179
|
const span = long && long.window_minutes !== 10080 ? long.window_minutes : undefined;
|
|
149
|
-
|
|
180
|
+
const r5 = reset(short), rw = reset(long);
|
|
181
|
+
const span5 = short && short.window_minutes && short.window_minutes !== 300 ? short.window_minutes : undefined;
|
|
182
|
+
return { five: pct(short), week: pct(long), ...(span ? { span } : {}), ...(span5 ? { span5 } : {}), ...(r5 !== undefined ? { reset5h: r5 } : {}),
|
|
183
|
+
...(rw !== undefined ? { resetWeek: rw } : {}) };
|
|
150
184
|
}
|
|
151
185
|
export function codexQuota() {
|
|
152
186
|
const f = latestCodexSession();
|
|
@@ -156,9 +190,9 @@ export function codexQuota() {
|
|
|
156
190
|
if (!q)
|
|
157
191
|
return noData("codex", m("no limit data", "sem dados de limite"));
|
|
158
192
|
const age = Math.round((Date.now() - statSync(f).mtimeMs) / 60_000);
|
|
159
|
-
const { span, ...pcts } = q;
|
|
193
|
+
const { span, span5, ...pcts } = q;
|
|
160
194
|
const d = span && Math.round(span / 1440);
|
|
161
|
-
return { name: "codex", ...pcts, note: (d ? m(`${d}-day window in the week slot; `, `janela de ${d} dias no lugar da semana; `) : "")
|
|
195
|
+
return { name: "codex", ...pcts, ...(span5 ? { span5h: span5 } : {}), ...(span ? { spanWeek: span } : {}), note: (d ? m(`${d}-day window in the week slot; `, `janela de ${d} dias no lugar da semana; `) : "")
|
|
162
196
|
+ m(`from the last session, ${age} min ago`, `da última sessão, há ${age} min`) };
|
|
163
197
|
}
|
|
164
198
|
/** Worst usage per window across models: a reset within 6 h belongs to the 5h window, a later one to the week. */
|
|
@@ -179,7 +213,7 @@ export function antigravityFromModels(models, now = Date.now()) {
|
|
|
179
213
|
}
|
|
180
214
|
return { five, week };
|
|
181
215
|
}
|
|
182
|
-
function agyToken() {
|
|
216
|
+
export function agyToken() {
|
|
183
217
|
// agy (go-keyring) keeps {"token": {access_token, expiry, ...}} under service "gemini", user "antigravity"
|
|
184
218
|
// (on Windows go-keyring names the credential "gemini:antigravity").
|
|
185
219
|
const raw = readSecret("gemini", "antigravity", "gemini:antigravity");
|
|
@@ -187,21 +221,34 @@ function agyToken() {
|
|
|
187
221
|
return undefined;
|
|
188
222
|
try {
|
|
189
223
|
const t = JSON.parse(raw).token;
|
|
190
|
-
|
|
224
|
+
// The refresh token is stable for a login and differs between accounts (the access token rotates hourly).
|
|
225
|
+
const id = t?.refresh_token ?? t?.id_token;
|
|
226
|
+
return { access: t?.access_token, expiry: t?.expiry, refresh: !!t?.refresh_token,
|
|
227
|
+
...(id ? { acct: createHash("sha256").update(String(id)).digest("hex").slice(0, 16) } : {}) };
|
|
191
228
|
}
|
|
192
229
|
catch {
|
|
193
230
|
return undefined;
|
|
194
231
|
}
|
|
195
232
|
}
|
|
233
|
+
/** Why the stored agy token can't be used as is: idle (expired, agy renews it on its next run) or login expired (no refresh token). */
|
|
234
|
+
export function agyNote(t, now = Date.now()) {
|
|
235
|
+
const at = t.expiry ? Date.parse(t.expiry) : NaN;
|
|
236
|
+
if (Number.isNaN(at) || at >= now)
|
|
237
|
+
return undefined;
|
|
238
|
+
return t.refresh ? m("token idle (agy refreshes on next run)", "token ocioso (o agy renova no próximo uso)")
|
|
239
|
+
: m("login expired: run `agy models`", "login expirado: rode `agy models`");
|
|
240
|
+
}
|
|
196
241
|
export async function antigravityQuota() {
|
|
197
242
|
const name = "antigravity";
|
|
198
243
|
const t = agyToken();
|
|
199
244
|
if (!t?.access)
|
|
200
245
|
return noData(name, m("no agy login in the keyring", "sem login do agy no chaveiro"));
|
|
201
|
-
// Never refresh it here (that is agy's login)
|
|
246
|
+
// Never refresh it here (that is agy's login). The access token lives 1h and agy renews it only when it runs, so a
|
|
247
|
+
// passed expiry with a refresh token is just idle (a note, not an expired login); without one, "run agy once".
|
|
202
248
|
const expired = m("login expired: run `agy models`", "login expirado: rode `agy models`");
|
|
203
|
-
|
|
204
|
-
|
|
249
|
+
const note = agyNote(t);
|
|
250
|
+
if (note)
|
|
251
|
+
return fromCache(name, note, undefined, t.acct);
|
|
205
252
|
try {
|
|
206
253
|
const r = await fetch("https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", {
|
|
207
254
|
method: "POST", body: "{}",
|
|
@@ -210,30 +257,45 @@ export async function antigravityQuota() {
|
|
|
210
257
|
signal: AbortSignal.timeout(15_000),
|
|
211
258
|
});
|
|
212
259
|
if (r.status === 401)
|
|
213
|
-
return fromCache(name, expired);
|
|
260
|
+
return fromCache(name, expired, undefined, t.acct);
|
|
214
261
|
if (!r.ok)
|
|
215
|
-
return fromCache(name, `HTTP ${r.status}
|
|
262
|
+
return fromCache(name, `HTTP ${r.status}`, undefined, t.acct);
|
|
216
263
|
const j = await r.json();
|
|
217
264
|
const { five, week } = antigravityFromModels(j.models ?? {});
|
|
218
|
-
return remember(name, five, week);
|
|
265
|
+
return remember(name, five, week, t.acct);
|
|
219
266
|
}
|
|
220
267
|
catch (e) {
|
|
221
|
-
return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}
|
|
268
|
+
return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}`, undefined, t.acct);
|
|
222
269
|
}
|
|
223
270
|
}
|
|
224
271
|
// --- copilot --------------------------------------------------------------------------------------------------------
|
|
272
|
+
/** The same moment one calendar month earlier; a day the month lacks (31 Mar → Feb) clamps to its last day. */
|
|
273
|
+
export function monthBefore(d) {
|
|
274
|
+
const out = new Date(d);
|
|
275
|
+
out.setDate(1);
|
|
276
|
+
out.setMonth(out.getMonth() - 1);
|
|
277
|
+
out.setDate(Math.min(d.getDate(), new Date(out.getFullYear(), out.getMonth() + 1, 0).getDate()));
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
225
280
|
/** Premium requests used this month, from the snapshot `gh api /copilot_internal/user` returns. No 5h window. */
|
|
226
281
|
export function copilotFromUser(j) {
|
|
227
282
|
const u = j;
|
|
228
283
|
const p = u?.quota_snapshots?.premium_interactions;
|
|
229
284
|
if (!p)
|
|
230
285
|
return noData("copilot", m("no premium request data", "sem dados de requisições premium"));
|
|
286
|
+
// A bare date ("2026-11-01") would parse as UTC midnight: local midnight matches the date the note itself shows
|
|
287
|
+
// (same fix as parseLocal in commands/block.ts).
|
|
288
|
+
const at = u.quota_reset_date
|
|
289
|
+
? Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(u.quota_reset_date) ? `${u.quota_reset_date}T00:00` : u.quota_reset_date)
|
|
290
|
+
: NaN;
|
|
291
|
+
const prev = Number.isNaN(at) ? new Date(NaN) : monthBefore(new Date(at));
|
|
292
|
+
const resetWeek = Number.isNaN(at) ? {} : { resetWeek: Math.floor(at / 1000), spanWeek: Math.round((at - prev.getTime()) / 60_000) };
|
|
231
293
|
if (p.unlimited)
|
|
232
294
|
return { name: "copilot", five: "-", week: "-", note: m("unlimited", "ilimitado") };
|
|
233
295
|
if (typeof p.percent_remaining !== "number")
|
|
234
296
|
return noData("copilot", m("no premium request data", "sem dados de requisições premium"));
|
|
235
297
|
const reset = u.quota_reset_date ? m(`, resets ${u.quota_reset_date}`, `, renova em ${u.quota_reset_date}`) : "";
|
|
236
|
-
return { name: "copilot", five: "-", week: Math.max(0, 100 - p.percent_remaining),
|
|
298
|
+
return { name: "copilot", five: "-", week: Math.max(0, 100 - p.percent_remaining), ...resetWeek,
|
|
237
299
|
note: m("monthly premium requests", "requisições premium do mês") + reset };
|
|
238
300
|
}
|
|
239
301
|
export function copilotQuota() {
|
|
@@ -266,7 +328,7 @@ export function minimaxFromRemains(j) {
|
|
|
266
328
|
return { five: worst("current_interval_remaining_percent", "end_time"),
|
|
267
329
|
week: worst("current_weekly_remaining_percent", "weekly_end_time") };
|
|
268
330
|
}
|
|
269
|
-
function minimaxKey() {
|
|
331
|
+
export function minimaxKey() {
|
|
270
332
|
if (process.env.MINIMAX_API_KEY)
|
|
271
333
|
return process.env.MINIMAX_API_KEY;
|
|
272
334
|
const data = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
|
package/dist/rounds.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Watchdog layer 1: per-issue round metrics from the files already in .orquestra/<ID>/, no LLM and no extra recording.
|
|
2
|
+
// fix-<n>.md the blockers sent to the implementer in fix round n (its mtime = the round started)
|
|
3
|
+
// review-<repo>.out.md the first review (round 0); review-<repo>-<n>.out.md the review after fix round n
|
|
4
|
+
// Rounds = highest fix-<n> (or the .rounds-<session> counter `orq agent -R` keeps). Findings are the tagged list items
|
|
5
|
+
// of each review ("- [blocker] src/a.ts:10 title", or a leading bold severity word in PT/EN: "1. **Bloqueante** — ...",
|
|
6
|
+
// "- **Sugestão — `a.ts:7–8`:** ..."; "**Critério 1**" is not one). Files may be markdown links; a ".worktrees/<dir>/" prefix is
|
|
7
|
+
// stripped so a file matches across rounds. A finding "repeats" when a later review has one on the same file
|
|
8
|
+
// with similar words (token overlap, see similar()).
|
|
9
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
export const SIMILAR_FILE = 0.4; // token overlap needed when both findings name the same file
|
|
12
|
+
export const SIMILAR_LOOSE = 0.6; // ... when a file is missing on either side
|
|
13
|
+
export const SLOW_FACTOR = 2; // a round this many times slower than the median of the others is flagged
|
|
14
|
+
export const SLOW_MIN_SECS = 600; // ... but only if it also took at least this long
|
|
15
|
+
export const REPEAT_WARN = 2; // a finding seen in this many reviews: warn
|
|
16
|
+
export const REPEAT_STOP = 3; // ... in this many: stop (it came back after two fix rounds)
|
|
17
|
+
export const HEAVY = new Set(["blocker", "major", "critical"]); // only these count towards repeats and the alert
|
|
18
|
+
const read = (f) => { try {
|
|
19
|
+
return readFileSync(f, "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return "";
|
|
23
|
+
} };
|
|
24
|
+
const STOP = new Set(["the", "a", "an", "of", "in", "on", "to", "is", "are", "and", "or", "for", "with", "not", "no", "this", "that", "it",
|
|
25
|
+
"de", "do", "da", "o", "os", "as", "um", "uma", "em", "no", "na", "para", "com", "que", "se", "não"]);
|
|
26
|
+
const words = (s) => new Set(s.toLowerCase().replace(/[^\p{L}\p{N}_]+/gu, " ").split(" ").filter((w) => w.length > 2 && !STOP.has(w)));
|
|
27
|
+
const SEV = {
|
|
28
|
+
bloqueante: "blocker", bloqueador: "blocker", blocker: "blocker", critico: "critical", critical: "critical",
|
|
29
|
+
importante: "major", important: "major", major: "major", menor: "minor", minor: "minor",
|
|
30
|
+
sugestao: "suggestion", suggestion: "suggestion", nit: "nit",
|
|
31
|
+
};
|
|
32
|
+
const fold = (w) => w.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
|
33
|
+
const URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
34
|
+
// GitHub-style ".../blob/<ref>/<path>" → "<path>" (no query, no #L4), so URL mentions match plain-path mentions.
|
|
35
|
+
const blobPath = (u) => /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/[^/]+\/[^/]+\/blob\/[^/]+\/([^#?\s)]+)/i.exec(u)?.[1];
|
|
36
|
+
const LINK = /\[([^\]]*)\]\(([^)\s]*)\)/g;
|
|
37
|
+
// Reviews run in a different worktree per round (".worktrees/<repo>-<id>.review-<pid>/"): drop it so the same file matches across rounds.
|
|
38
|
+
const repoRel = (f) => f.replace(/\\/g, "/").replace(/^.*\.worktrees\/[^/]+\//, "");
|
|
39
|
+
/** One review-list item → finding. Only items carrying a severity (a "[blocker]" tag or a leading bold "**Bloqueante**") count. */
|
|
40
|
+
export function parseFindings(text, round) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const line of text.split("\n")) {
|
|
43
|
+
const it = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
44
|
+
if (!it)
|
|
45
|
+
continue;
|
|
46
|
+
const sevOf = (w) => { const k = fold(w ?? ""); return Object.hasOwn(SEV, k) ? SEV[k] : undefined; };
|
|
47
|
+
let m0 = /^\*\*\s*(\p{L}+)/u.exec(it[1]); // a leading bold severity wins over a [tag]
|
|
48
|
+
if (!sevOf(m0?.[1]))
|
|
49
|
+
m0 = /\[(blocker|major|minor|nit|suggestion|important|critical)\]/i.exec(it[1]);
|
|
50
|
+
const sev = sevOf(m0?.[1]);
|
|
51
|
+
if (!m0 || !sev)
|
|
52
|
+
continue;
|
|
53
|
+
const rest = it[1].replace(m0[0], " ");
|
|
54
|
+
const byTarget = rest.replace(LINK, (_, t, p) => ` ${URL.test(p) ? blobPath(p) ?? t : repoRel(p)} `)
|
|
55
|
+
.replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, (u) => ` ${blobPath(u) ?? ""} `);
|
|
56
|
+
const m = /([\w@./\\-]+\.\w+)(?::\d+(?:[-\u2013]\d+)?)?/.exec(byTarget);
|
|
57
|
+
const file = m ? repoRel(m[1]) : "";
|
|
58
|
+
out.push({ round, severity: sev, file, title: rest.replace(LINK, "$1").replace(/[`*_]/g, "").replace(/\s+/g, " ").trim() });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Same finding? Same file (when both name one) and enough shared words; without a file, a stricter overlap. */
|
|
63
|
+
export function similar(a, b) {
|
|
64
|
+
const wa = words(a.title), wb = words(b.title);
|
|
65
|
+
if (!wa.size || !wb.size)
|
|
66
|
+
return false;
|
|
67
|
+
let inter = 0;
|
|
68
|
+
for (const w of wa)
|
|
69
|
+
if (wb.has(w))
|
|
70
|
+
inter++;
|
|
71
|
+
const j = inter / (wa.size + wb.size - inter), both = a.file && b.file;
|
|
72
|
+
if (both && a.file !== b.file)
|
|
73
|
+
return false;
|
|
74
|
+
return j >= (both ? SIMILAR_FILE : SIMILAR_LOOSE);
|
|
75
|
+
}
|
|
76
|
+
/** Findings that reappear in more than one round. Each group is matched against its first member. */
|
|
77
|
+
export function repeats(fs) {
|
|
78
|
+
const groups = [];
|
|
79
|
+
for (const f of [...fs].sort((a, b) => a.round - b.round)) {
|
|
80
|
+
const g = groups.find((x) => similar(x[0], f));
|
|
81
|
+
if (g)
|
|
82
|
+
g.push(f);
|
|
83
|
+
else
|
|
84
|
+
groups.push([f]);
|
|
85
|
+
}
|
|
86
|
+
return groups.map((g) => ({ file: g[0].file, title: g[0].title, rounds: [...new Set(g.map((x) => x.round))] }))
|
|
87
|
+
.filter((r) => r.rounds.length >= REPEAT_WARN);
|
|
88
|
+
}
|
|
89
|
+
const median = (xs) => { const s = [...xs].sort((a, b) => a - b), h = s.length >> 1; return s.length % 2 ? s[h] : (s[h - 1] + s[h]) / 2; };
|
|
90
|
+
/** Fix rounds far above the median of the other fix rounds (round 0 is the implementation: never compared; needs 3 fix rounds). */
|
|
91
|
+
export function slowRounds(all) {
|
|
92
|
+
const per = all.filter((r) => r.round > 0);
|
|
93
|
+
if (per.length < 3)
|
|
94
|
+
return [];
|
|
95
|
+
return per.filter((r) => r.secs >= SLOW_MIN_SECS && r.secs > SLOW_FACTOR * median(per.filter((x) => x !== r).map((x) => x.secs))).map((r) => r.round);
|
|
96
|
+
}
|
|
97
|
+
/** The pure part: facts from the files → numbers, alert level and why. */
|
|
98
|
+
export function metrics(issue, f) {
|
|
99
|
+
const reps = repeats(f.findings.filter((x) => HEAVY.has(x.severity))), reasons = [];
|
|
100
|
+
let level = "ok";
|
|
101
|
+
const raise = (l, why) => { reasons.push(why); if (l === "stop" || level === "ok")
|
|
102
|
+
level = l; };
|
|
103
|
+
if (f.rounds > f.max)
|
|
104
|
+
raise("stop", { code: "over_max", n: f.rounds, max: f.max });
|
|
105
|
+
else if (f.rounds >= f.max && f.max > 0)
|
|
106
|
+
raise("warn", { code: "at_max", n: f.rounds, max: f.max });
|
|
107
|
+
const worst = Math.max(0, ...reps.map((r) => r.rounds.length));
|
|
108
|
+
if (worst >= REPEAT_STOP)
|
|
109
|
+
raise("stop", { code: "same_finding", n: worst });
|
|
110
|
+
else if (reps.length)
|
|
111
|
+
raise("warn", { code: "repeated", n: reps.length });
|
|
112
|
+
const slow = slowRounds(f.perRound);
|
|
113
|
+
if (slow.length)
|
|
114
|
+
raise("warn", { code: "slow", n: slow.length, rounds: slow });
|
|
115
|
+
return { issue, rounds: f.rounds, max: f.max, secs: f.secs, perRound: f.perRound, findings: f.findings.length, repeats: reps, level, reasons };
|
|
116
|
+
}
|
|
117
|
+
/** The impure part: read .orquestra/<issue>/. undefined when the issue has no fix or review files at all. */
|
|
118
|
+
export function collectRounds(ws, issue, max) {
|
|
119
|
+
const dir = join(ws, ".orquestra", issue);
|
|
120
|
+
let names;
|
|
121
|
+
try {
|
|
122
|
+
names = readdirSync(dir);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const mt = (n) => statSync(join(dir, n)).mtimeMs;
|
|
128
|
+
const fixes = new Map(), reviews = new Map();
|
|
129
|
+
let counter = 0;
|
|
130
|
+
for (const n of names) {
|
|
131
|
+
const fx = /^fix-(\d+)\.md$/.exec(n);
|
|
132
|
+
if (fx)
|
|
133
|
+
fixes.set(Number(fx[1]), mt(n));
|
|
134
|
+
}
|
|
135
|
+
for (const n of names) {
|
|
136
|
+
// A trailing -<n> is a round only when fix-<n>.md exists; otherwise it is part of the repo name (round 0).
|
|
137
|
+
const rv = /^review-(.+?)(?:-(\d+))?\.out\.md$/.exec(n), k = rv && rv[2] !== undefined && fixes.has(Number(rv[2])) ? Number(rv[2]) : 0;
|
|
138
|
+
if (rv)
|
|
139
|
+
reviews.set(k, [...(reviews.get(k) ?? []), n]);
|
|
140
|
+
else if (n.startsWith(".rounds-"))
|
|
141
|
+
counter = Math.max(counter, Number(read(join(dir, n))) || 0);
|
|
142
|
+
}
|
|
143
|
+
if (!reviews.size && !fixes.size)
|
|
144
|
+
return undefined;
|
|
145
|
+
const rounds = Math.max(counter, 0, ...fixes.keys());
|
|
146
|
+
const all = names.filter((n) => !n.startsWith(".")).map(mt), t0 = Math.min(...all), t1 = Math.max(...all);
|
|
147
|
+
// Round k runs from fix-k.md (round 0: the oldest file) to the next fix, the last one to the newest file.
|
|
148
|
+
const starts = [t0, ...Array.from({ length: rounds }, (_, i) => fixes.get(i + 1) ?? NaN)];
|
|
149
|
+
const perRound = starts.map((s, k) => {
|
|
150
|
+
const next = k < rounds ? starts[k + 1] : t1;
|
|
151
|
+
return { round: k, secs: Number.isNaN(s) || Number.isNaN(next) ? 0 : Math.max(0, Math.round((next - s) / 1000)) };
|
|
152
|
+
});
|
|
153
|
+
const findings = [...reviews].flatMap(([k, fs]) => fs.flatMap((n) => parseFindings(read(join(dir, n)), k)));
|
|
154
|
+
return metrics(issue, { rounds, max, perRound, findings, secs: Math.round((t1 - t0) / 1000) });
|
|
155
|
+
}
|
|
156
|
+
/** Cheap change signature of an issue folder (names + mtimes, no reads) for caching collectRounds between redraws. */
|
|
157
|
+
export function roundsSig(ws, issue) {
|
|
158
|
+
const dir = join(ws, ".orquestra", issue);
|
|
159
|
+
try {
|
|
160
|
+
return readdirSync(dir).filter((n) => /^(fix-\d+\.md|review-.+\.out\.md|\.rounds-.+)$/.test(n)).map((n) => `${n}:${statSync(join(dir, n)).mtimeMs}`).join("|");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Board marker: "r2/2 ⚠", "r3/2 ✗"; just "r1/2" when ok. */
|
|
167
|
+
export const mark = (x) => `r${x.rounds}/${x.max}${x.level === "warn" ? " ⚠" : x.level === "stop" ? " ✗" : ""}`;
|
package/dist/testrun.js
CHANGED
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { writeAtomic } from "./fsutil.js";
|
|
10
10
|
import { m } from "./i18n.js";
|
|
11
11
|
import { acquire, release } from "./lock.js";
|
|
12
|
+
import { stateDir } from "./paths.js";
|
|
12
13
|
import { killTree, parseSize, runCapped } from "./process.js";
|
|
13
14
|
import { closeOwnTab, detectHost, launch } from "./terminal.js";
|
|
14
15
|
export const JS_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
|
|
@@ -196,12 +197,28 @@ export async function inJob(j, label, slots, body, done) {
|
|
|
196
197
|
}
|
|
197
198
|
return st === "ok" ? 0 : 1;
|
|
198
199
|
}
|
|
200
|
+
/** Where `orq handoff` records the pane that replaced a conductor's. */
|
|
201
|
+
export const handoffFile = (pane) => join(stateDir(), "handoff", pane.replace(/\W/g, "_"));
|
|
199
202
|
/** The conductor is a herdr pane: type the line into it (herdr-notify waits for it to leave a dialog). In the
|
|
200
203
|
* background when the caller must not wait for that (an agent's end-of-turn hook holds its screen meanwhile). */
|
|
201
204
|
export function notify(pane, line, background = false) {
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
while (!seen.has(pane) && existsSync(handoffFile(pane))) {
|
|
207
|
+
seen.add(pane);
|
|
208
|
+
let next;
|
|
209
|
+
try {
|
|
210
|
+
next = readFileSync(handoffFile(pane), "utf8").trim();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
break;
|
|
214
|
+
} // gone between the two calls
|
|
215
|
+
if (!next || next === pane)
|
|
216
|
+
break;
|
|
217
|
+
pane = next; // follow repeated handoffs to the current conductor
|
|
218
|
+
}
|
|
202
219
|
const local = join(dirname(CLI), "..", "setup", "herdr-notify"), cmd = existsSync(local) ? local : "herdr-notify";
|
|
203
220
|
if (background)
|
|
204
|
-
spawn(cmd, [pane, line], { detached: true, stdio: "ignore" }).unref();
|
|
221
|
+
spawn(cmd, [pane, line], { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
|
|
205
222
|
else
|
|
206
223
|
spawnSync(cmd, [pane, line], { stdio: "inherit" });
|
|
207
224
|
}
|