agent-from-zero 0.34.2 → 0.35.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 +147 -0
- package/dist/account-status.js +172 -0
- package/dist/agent-progress.js +39 -0
- package/dist/agent.js +159 -34
- package/dist/auto-budget.js +15 -0
- package/dist/auto-context.js +125 -0
- package/dist/auto-debug.js +87 -0
- package/dist/auto-providers.js +154 -0
- package/dist/auto-review.js +113 -0
- package/dist/auto.js +295 -0
- package/dist/background.js +2 -1
- package/dist/cli.js +83 -0
- package/dist/clipboard.js +88 -0
- package/dist/completion.js +65 -0
- package/dist/config.js +8 -0
- package/dist/context.js +9 -3
- package/dist/cost.js +4 -0
- package/dist/effort.js +81 -0
- package/dist/follow-up.js +22 -0
- package/dist/form.js +8 -6
- package/dist/images.js +43 -0
- package/dist/ink/activity.js +62 -0
- package/dist/ink/agents.js +24 -0
- package/dist/ink/app.js +384 -171
- package/dist/ink/chat.js +12 -1
- package/dist/ink/commands.js +16 -3
- package/dist/ink/completion.js +43 -0
- package/dist/ink/setup.js +9 -4
- package/dist/ink/sink.js +16 -6
- package/dist/ink/viewport.js +15 -0
- package/dist/loop.js +251 -106
- package/dist/mcp.js +1 -0
- package/dist/menu.js +50 -8
- package/dist/output.js +1 -1
- package/dist/pasted-text.js +32 -0
- package/dist/permissions.js +467 -12
- package/dist/print.js +16 -0
- package/dist/prompt.js +7 -4
- package/dist/status.js +80 -0
- package/dist/team.js +9 -3
- package/dist/tools.js +13 -1
- package/dist/tui.js +31 -15
- package/dist/ui.js +4 -6
- package/package.json +4 -3
package/dist/auto.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ruleHistory, startupRemotes } from "./auto-context.js";
|
|
3
|
+
import { decideRules, screenRules, RULE_ROUTING_VERSION, JEV_CLEAR_RULE_MAX, RULE_POLICY_VERSION } from "./auto-review.js";
|
|
4
|
+
import { CONFIG } from "./config.js";
|
|
5
|
+
import { emit } from "./telemetry.js";
|
|
6
|
+
import { beginReviewDebug, reviewDebugPath } from "./auto-debug.js";
|
|
7
|
+
import { decideReview, needsAuthorizationReview } from "./auto-review.js";
|
|
8
|
+
import { jevReviewer, modelReviewer, modelRuleReviewer, jevRuleReviewer, parseJevAssessment, parseModelAssessment, ReviewProviderUnavailable } from "./auto-providers.js";
|
|
9
|
+
const TIMEOUT_MS = 30_000;
|
|
10
|
+
const MAX_STATE_BYTES = 24_000;
|
|
11
|
+
// Sizes behind the two known cliffs, recorded on every verdict: stateBytes over
|
|
12
|
+
// MAX_STATE_BYTES forces a human prompt, and omittedActions > 0 disables the Jev
|
|
13
|
+
// fast pass. The parts say WHICH input grew (pasted requests vs. a big write).
|
|
14
|
+
const bytes = (value) => Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
|
|
15
|
+
function stateSize(state) {
|
|
16
|
+
return { stateBytes: bytes(state), requestBytes: bytes(state.userRequests), historyBytes: bytes(state.history.actions),
|
|
17
|
+
actionBytes: bytes(state.action.args), omittedActions: state.history.omittedActions };
|
|
18
|
+
}
|
|
19
|
+
// Compatibility helpers use the exact same parsers and policy as live review.
|
|
20
|
+
export function interpretAutoOutput(value) {
|
|
21
|
+
const assessment = parseJevAssessment(value);
|
|
22
|
+
if (!assessment)
|
|
23
|
+
return null;
|
|
24
|
+
const verdict = decideReview(assessment);
|
|
25
|
+
return { ...verdict, reason: `Jev: ${verdict.reason}` };
|
|
26
|
+
}
|
|
27
|
+
export function interpretModelOutput(text) {
|
|
28
|
+
const assessment = parseModelAssessment(text);
|
|
29
|
+
return assessment ? decideReview(assessment) : null;
|
|
30
|
+
}
|
|
31
|
+
// Each provider gets a fresh deadline. A Jev timeout must not hand an already
|
|
32
|
+
// aborted signal to the fallback; a USER cancellation must abort both paths.
|
|
33
|
+
async function withDeadline(signal, run) {
|
|
34
|
+
signal.throwIfAborted();
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const abort = () => controller.abort();
|
|
37
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
38
|
+
const timer = setTimeout(abort, TIMEOUT_MS);
|
|
39
|
+
try {
|
|
40
|
+
const result = await run(controller.signal);
|
|
41
|
+
controller.signal.throwIfAborted();
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
signal.removeEventListener("abort", abort);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class AutoMode {
|
|
50
|
+
enabled;
|
|
51
|
+
policy;
|
|
52
|
+
ruleModel;
|
|
53
|
+
ruleJev;
|
|
54
|
+
workingDirectory = process.cwd();
|
|
55
|
+
remotes = startupRemotes(this.workingDirectory);
|
|
56
|
+
errors = 0;
|
|
57
|
+
requests = [];
|
|
58
|
+
apiKey;
|
|
59
|
+
jev;
|
|
60
|
+
model;
|
|
61
|
+
projectInstructions;
|
|
62
|
+
jevFailed = false; // sticky failover: do not retry an exhausted key on every tool
|
|
63
|
+
constructor(client, options = {}) {
|
|
64
|
+
this.enabled = CONFIG.autoMode.enabled && !CONFIG.bypassPermissions;
|
|
65
|
+
this.policy = options.policy ?? CONFIG.autoMode.policy;
|
|
66
|
+
this.ruleModel = client ? modelRuleReviewer(client, () => CONFIG.judge.model || CONFIG.model) : undefined;
|
|
67
|
+
this.apiKey = options.apiKey ?? (process.env.JEV_API_KEY?.trim() || process.env.TYPESAFE_API_KEY?.trim() || "");
|
|
68
|
+
this.ruleJev = this.apiKey ? jevRuleReviewer(this.apiKey, () => CONFIG.autoMode.model, options.request ?? fetch) : undefined;
|
|
69
|
+
this.jev = this.apiKey ? jevReviewer(this.apiKey, () => CONFIG.autoMode.model, options.request ?? fetch) : undefined;
|
|
70
|
+
this.model = client ? modelReviewer(client, () => CONFIG.judge.model || CONFIG.model) : undefined;
|
|
71
|
+
this.projectInstructions = options.projectInstructions ?? "";
|
|
72
|
+
}
|
|
73
|
+
// Resolve the fallback model at call time: /model changes must take effect
|
|
74
|
+
// when no explicit judge.model was configured. Its client/baseURL/key always
|
|
75
|
+
// come from the same vendor as the main agent.
|
|
76
|
+
get useJev() { return !!this.apiKey && !this.jevFailed; }
|
|
77
|
+
get backend() { return this.policy === "rules" ? `${this.useJev ? "Jev screen → " : ""}rule reviewer (${CONFIG.judge.model || CONFIG.model})` : this.useJev ? `Jev (${CONFIG.autoMode.model})` : `model judge (${CONFIG.judge.model || CONFIG.model})`; }
|
|
78
|
+
startupNotices() {
|
|
79
|
+
if (CONFIG.bypassPermissions)
|
|
80
|
+
return [this.status()];
|
|
81
|
+
if (this.policy === "rules")
|
|
82
|
+
return [this.status(), this.useJev ? "Rules preview: Jev screens first; flagged or uncertain actions receive one model review." : "Tip: set JEV_API_KEY (or TYPESAFE_API_KEY) for Jev screening; currently using the model reviewer.", ...(reviewDebugPath() ? [`Review debug log: ${reviewDebugPath()}`] : [])];
|
|
83
|
+
return [
|
|
84
|
+
this.status(),
|
|
85
|
+
...(reviewDebugPath() ? [`Review debug log: ${reviewDebugPath()} (local conversation/tool data; credentials redacted).`] : []),
|
|
86
|
+
...(!this.apiKey ? ["Tip: mini-agent supports Jev for permission review. Set JEV_API_KEY (or TYPESAFE_API_KEY) to use it automatically; otherwise /auto uses your current vendor (judge.model overrides the model)."] : []),
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
// Called ONLY at human input boundaries (text or submitted form answers),
|
|
90
|
+
// before @files, hooks, skills or
|
|
91
|
+
// summaries are appended. Never infer provenance from role:"user": that role
|
|
92
|
+
// also carries model-written summaries and teammate notifications here.
|
|
93
|
+
recordRequest(text) {
|
|
94
|
+
if (text.trim())
|
|
95
|
+
this.requests.push(text);
|
|
96
|
+
}
|
|
97
|
+
snapshot() { return [...this.requests]; }
|
|
98
|
+
clearRequests() { this.requests = []; }
|
|
99
|
+
toggle() {
|
|
100
|
+
if (CONFIG.bypassPermissions)
|
|
101
|
+
return this.status(); // CLI bypass lasts until exit
|
|
102
|
+
this.enabled = !this.enabled;
|
|
103
|
+
this.errors = 0;
|
|
104
|
+
this.jevFailed = false;
|
|
105
|
+
return this.status();
|
|
106
|
+
}
|
|
107
|
+
status() {
|
|
108
|
+
if (CONFIG.bypassPermissions)
|
|
109
|
+
return "(bypassPermissions — approvals and AI review skipped for this run; hard denies, plan restrictions and hooks still apply)";
|
|
110
|
+
return `(auto mode ${this.enabled ? "ON" : "OFF"} — ${this.backend}; ${this.enabled ? "uncertain or risky actions require approval" : "use /auto to enable"})`;
|
|
111
|
+
}
|
|
112
|
+
async classify(tool, argsJson, userRequests, signal, options = {}) {
|
|
113
|
+
if (this.policy === "rules")
|
|
114
|
+
return this.classifyRules(tool, argsJson, userRequests, signal, options);
|
|
115
|
+
// One ID per review joins the debug log, this verdict event and the human's
|
|
116
|
+
// later decision in loop.ts — the join that turns logs into labeled data.
|
|
117
|
+
const reviewId = randomUUID();
|
|
118
|
+
const started = Date.now();
|
|
119
|
+
const measured = {};
|
|
120
|
+
const finish = (verdict, fields = {}) => {
|
|
121
|
+
emit("agent_auto_verdict", { reviewId, policy: "scores", tool, verdict: verdict.decision, ...measured, ...fields, durationMs: Date.now() - started });
|
|
122
|
+
return { ...verdict, reviewId };
|
|
123
|
+
};
|
|
124
|
+
const ask = (reason) => finish({ decision: "ask", reason }, { outcome: "precondition" });
|
|
125
|
+
if (!this.jev && !this.model)
|
|
126
|
+
return ask("Model judge unavailable; manual approval required");
|
|
127
|
+
if (this.errors >= 3)
|
|
128
|
+
return ask("Auto review disabled after 3 consecutive failures; manual approval required (toggle /auto off and on to retry)");
|
|
129
|
+
if (signal.aborted)
|
|
130
|
+
return ask("Auto review interrupted");
|
|
131
|
+
if (!userRequests.length)
|
|
132
|
+
return ask("Auto review needs a fresh user request; restored/synthetic messages are not authorization");
|
|
133
|
+
let args;
|
|
134
|
+
try {
|
|
135
|
+
args = JSON.parse(argsJson);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return ask("Invalid tool arguments");
|
|
139
|
+
}
|
|
140
|
+
const state = {
|
|
141
|
+
reviewId, userRequests, projectInstructions: this.projectInstructions,
|
|
142
|
+
workingDirectory: process.cwd(), deniedBySettings: CONFIG.permissions.deny,
|
|
143
|
+
history: options.history ?? { actions: [], omittedActions: 0 }, delegatedTask: options.delegatedTask,
|
|
144
|
+
action: { tool, args, description: options.description },
|
|
145
|
+
};
|
|
146
|
+
Object.assign(measured, stateSize(state));
|
|
147
|
+
// Never silently truncate an action or earlier restrictions to get an allow.
|
|
148
|
+
if (measured.stateBytes > MAX_STATE_BYTES)
|
|
149
|
+
return ask("Auto review context is too large; manual approval required");
|
|
150
|
+
let activeBackend = this.backend;
|
|
151
|
+
try {
|
|
152
|
+
let assessment = null;
|
|
153
|
+
let backend = "model";
|
|
154
|
+
let priorAssessment = "";
|
|
155
|
+
if (this.useJev) {
|
|
156
|
+
try {
|
|
157
|
+
assessment = await withDeadline(signal, (reviewSignal) => this.jev.review(state, reviewSignal));
|
|
158
|
+
backend = "jev";
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (signal.aborted || !this.model)
|
|
162
|
+
throw error;
|
|
163
|
+
if (!this.jevFailed) {
|
|
164
|
+
this.jevFailed = true;
|
|
165
|
+
const reason = error instanceof ReviewProviderUnavailable ? error.message : "unavailable or timed out";
|
|
166
|
+
options.notify?.(`Tip: Jev ${reason}; falling back to ${this.backend} for this session.`);
|
|
167
|
+
emit("agent_auto_fallback", { reason, model: CONFIG.judge.model || CONFIG.model });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Distinguish an uncertain authorization from a refusal. This one-shot
|
|
172
|
+
// second stage uses the same state/policy, independently judges BOTH
|
|
173
|
+
// dimensions, and never changes the session's preferred backend.
|
|
174
|
+
if (assessment && this.model && needsAuthorizationReview(assessment)) {
|
|
175
|
+
signal.throwIfAborted();
|
|
176
|
+
priorAssessment = `Jev authorization=${assessment.authorized}, risk=${assessment.risky} → `;
|
|
177
|
+
activeBackend = `model judge (${CONFIG.judge.model || CONFIG.model})`;
|
|
178
|
+
const debug = beginReviewDebug("authorization-review", { state, jevAssessment: assessment, reviewer: activeBackend }, [this.apiKey], reviewId);
|
|
179
|
+
emit("agent_auto_authorization_review", { tool, model: CONFIG.judge.model || CONFIG.model });
|
|
180
|
+
try {
|
|
181
|
+
assessment = await withDeadline(signal, (reviewSignal) => this.model.review(state, reviewSignal));
|
|
182
|
+
debug("assessment", { assessment, verdict: decideReview(assessment) });
|
|
183
|
+
backend = "model";
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
debug("error", { error: error instanceof Error ? error.message : String(error) });
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!assessment) {
|
|
191
|
+
activeBackend = `model judge (${CONFIG.judge.model || CONFIG.model})`;
|
|
192
|
+
assessment = await withDeadline(signal, (reviewSignal) => this.model.review(state, reviewSignal));
|
|
193
|
+
}
|
|
194
|
+
const verdict = decideReview(assessment);
|
|
195
|
+
verdict.reason = `${priorAssessment}${backend === "jev" ? "Jev" : activeBackend}: ${verdict.reason}`;
|
|
196
|
+
if (signal.aborted)
|
|
197
|
+
return ask("Auto review interrupted");
|
|
198
|
+
this.errors = 0;
|
|
199
|
+
return finish(verdict, { outcome: "reviewed", backend,
|
|
200
|
+
model: backend === "jev" ? CONFIG.autoMode.model : CONFIG.judge.model || CONFIG.model,
|
|
201
|
+
authorization: typeof assessment.authorized === "number" ? assessment.authorized : String(assessment.authorized),
|
|
202
|
+
risk: typeof assessment.risky === "number" ? assessment.risky : String(assessment.risky) });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
if (!signal.aborted)
|
|
206
|
+
this.errors++;
|
|
207
|
+
emit("agent_auto_unavailable", { tool, disabled: Number(this.errors >= 3) });
|
|
208
|
+
return finish({ decision: "ask", reason: `${activeBackend} unavailable, timed out or returned an invalid response; manual approval required` }, { outcome: "unavailable" });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async classifyRules(tool, argsJson, userRequests, signal, options) {
|
|
212
|
+
const reviewId = randomUUID();
|
|
213
|
+
const policyVersion = RULE_POLICY_VERSION;
|
|
214
|
+
let backend = "model";
|
|
215
|
+
const debug = beginReviewDebug("rules", { reviewId, policyVersion, routingVersion: RULE_ROUTING_VERSION, cutoff: JEV_CLEAR_RULE_MAX, tool }, [this.apiKey], reviewId);
|
|
216
|
+
const started = Date.now();
|
|
217
|
+
const measured = {};
|
|
218
|
+
let route = "none"; // how the review was routed: jev_allow, jev_flagged, history_truncated, jev_unavailable, no_jev
|
|
219
|
+
const finish = (verdict) => {
|
|
220
|
+
debug("verdict", { policyVersion, routingVersion: RULE_ROUTING_VERSION, backend, verdict });
|
|
221
|
+
emit("agent_auto_verdict", { reviewId, policy: "rules", policyVersion, tool, backend, model: backend === "jev" ? CONFIG.autoMode.model : CONFIG.judge.model || CONFIG.model, verdict: verdict.decision, rules: (verdict.ruleIds ?? []).join(","), route, ...measured, durationMs: Date.now() - started });
|
|
222
|
+
return { ...verdict, reviewId, reason: `${backend === "jev" ? "Jev" : `rule reviewer (${CONFIG.judge.model || CONFIG.model})`}: ${verdict.reason}` };
|
|
223
|
+
};
|
|
224
|
+
const ask = (reason) => finish({ decision: "ask", ruleIds: [], reason });
|
|
225
|
+
if (signal.aborted)
|
|
226
|
+
return ask("Review interrupted");
|
|
227
|
+
if (!this.ruleModel)
|
|
228
|
+
return ask("Model reviewer unavailable; Jev scores cannot replace rule review");
|
|
229
|
+
if (this.errors >= 3)
|
|
230
|
+
return ask("Review disabled after 3 failures; toggle /auto to retry");
|
|
231
|
+
if (!userRequests.length)
|
|
232
|
+
return ask("A genuine human request is required");
|
|
233
|
+
let args;
|
|
234
|
+
try {
|
|
235
|
+
args = JSON.parse(argsJson);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return ask("Invalid tool arguments");
|
|
239
|
+
}
|
|
240
|
+
const state = {
|
|
241
|
+
reviewId, policyVersion, userRequests, projectInstructions: this.projectInstructions,
|
|
242
|
+
workingDirectory: this.workingDirectory, startupRemotes: this.remotes, deniedBySettings: [...CONFIG.permissions.deny],
|
|
243
|
+
history: ruleHistory(options.history ?? { actions: [], omittedActions: 0 }), delegatedTask: options.delegatedTask,
|
|
244
|
+
action: { tool, args, description: tool === "run_bash" || tool === "run_bash_background" ? "Execute the supplied shell command." : options.description },
|
|
245
|
+
};
|
|
246
|
+
Object.assign(measured, stateSize(state));
|
|
247
|
+
if (measured.stateBytes > MAX_STATE_BYTES)
|
|
248
|
+
return ask("Review context too large; no evidence was silently truncated");
|
|
249
|
+
try {
|
|
250
|
+
if (this.useJev && this.ruleJev) {
|
|
251
|
+
try {
|
|
252
|
+
const screening = await withDeadline(signal, (s) => this.ruleJev.review(state, s));
|
|
253
|
+
signal.throwIfAborted();
|
|
254
|
+
const routing = screenRules(screening, state.history);
|
|
255
|
+
debug("routing", { routingVersion: RULE_ROUTING_VERSION, cutoff: JEV_CLEAR_RULE_MAX, ...routing });
|
|
256
|
+
route = routing.route === "allow" ? "jev_allow" : state.history.omittedActions > 0 ? "history_truncated" : "jev_flagged";
|
|
257
|
+
if (routing.route === "allow") {
|
|
258
|
+
this.errors = 0;
|
|
259
|
+
backend = "jev";
|
|
260
|
+
return finish({ decision: "allow", ruleIds: [], reason: routing.reason });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (signal.aborted)
|
|
265
|
+
throw error;
|
|
266
|
+
if (!this.jevFailed) {
|
|
267
|
+
this.jevFailed = true;
|
|
268
|
+
const reason = error instanceof ReviewProviderUnavailable ? error.message : "unavailable or timed out";
|
|
269
|
+
options.notify?.(`Tip: Jev ${reason}; falling back to ${this.backend} for this session.`);
|
|
270
|
+
emit("agent_auto_fallback", { reason, model: CONFIG.judge.model || CONFIG.model });
|
|
271
|
+
}
|
|
272
|
+
debug("routing", { routingVersion: RULE_ROUTING_VERSION, route: "review", reason: "Jev unavailable" });
|
|
273
|
+
route = "jev_unavailable";
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
route = "no_jev";
|
|
278
|
+
debug("routing", { routingVersion: RULE_ROUTING_VERSION, route: "review", reason: this.jevFailed ? "Jev disabled after failure" : "No Jev key" });
|
|
279
|
+
}
|
|
280
|
+
// Same state, without first-stage scores. A suspicion is not a confirmed
|
|
281
|
+
// rule match; the reasoning model evaluates the evidence independently.
|
|
282
|
+
const assessment = await withDeadline(signal, (s) => this.ruleModel.review(state, s));
|
|
283
|
+
if (signal.aborted)
|
|
284
|
+
return ask("Review interrupted");
|
|
285
|
+
this.errors = 0;
|
|
286
|
+
return finish(decideRules(assessment));
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
debug("error", { error: error instanceof Error ? error.message : String(error) });
|
|
290
|
+
if (!signal.aborted)
|
|
291
|
+
this.errors++;
|
|
292
|
+
return ask(signal.aborted ? "Review interrupted" : "Model reviewer unavailable, timed out or returned invalid rules");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
package/dist/background.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// as events while the main loop runs. There is no shared-memory race to guard,
|
|
18
18
|
// so the whole "daemon thread + mutex" dance collapses into plain callbacks.
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
+
import { childEnv } from "./auto-debug.js";
|
|
20
21
|
const tasks = new Map(); // id → task, for the whole session
|
|
21
22
|
let seq = 0; // increments per task — seeds the id
|
|
22
23
|
const OUTPUT_CAP = 80_000; // chars of output retained per task (keep the tail)
|
|
@@ -38,7 +39,7 @@ function append(task, chunk) {
|
|
|
38
39
|
// to survive into later turns; only killAll, on session exit, stops them).
|
|
39
40
|
export function startBackground(command) {
|
|
40
41
|
const id = `bg_${++seq}`;
|
|
41
|
-
const child = spawn(command, { shell: true, stdio: ["ignore", "pipe", "pipe"] }); // shell:true → bash semantics, same as run_bash
|
|
42
|
+
const child = spawn(command, { shell: true, stdio: ["ignore", "pipe", "pipe"], env: childEnv() }); // shell:true → bash semantics, same as run_bash
|
|
42
43
|
const task = {
|
|
43
44
|
id,
|
|
44
45
|
command,
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { CONFIG } from "./config.js";
|
|
3
|
+
import { effortMenu, setEffort } from "./effort.js";
|
|
4
|
+
export class CliUsageError extends Error {
|
|
5
|
+
}
|
|
6
|
+
// -p is a mode, not a boundary after which flags turn into task text. Node's
|
|
7
|
+
// parser handles --option=value and the -- delimiter without another dependency.
|
|
8
|
+
export function parseCli(args) {
|
|
9
|
+
const exec = args[0] === "exec";
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = parseArgs({ args: exec ? args.slice(1) : args, strict: true, allowPositionals: true, options: {
|
|
13
|
+
print: { type: "boolean", short: "p" },
|
|
14
|
+
prompt: { type: "string" },
|
|
15
|
+
model: { type: "string" },
|
|
16
|
+
effort: { type: "string" },
|
|
17
|
+
auto: { type: "boolean" },
|
|
18
|
+
"permission-mode": { type: "string" },
|
|
19
|
+
"dangerously-skip-permissions": { type: "boolean" },
|
|
20
|
+
resume: { type: "boolean", short: "r" },
|
|
21
|
+
help: { type: "boolean", short: "h" },
|
|
22
|
+
version: { type: "boolean", short: "v" },
|
|
23
|
+
"output-format": { type: "string" },
|
|
24
|
+
} });
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw new CliUsageError(error.message);
|
|
28
|
+
}
|
|
29
|
+
const { values, positionals } = parsed;
|
|
30
|
+
const print = exec || values.print === true;
|
|
31
|
+
const requestedMode = values["permission-mode"];
|
|
32
|
+
if (requestedMode !== undefined && requestedMode !== "default" && requestedMode !== "auto" && requestedMode !== "bypassPermissions") {
|
|
33
|
+
throw new CliUsageError("--permission-mode must be default, auto, or bypassPermissions");
|
|
34
|
+
}
|
|
35
|
+
// Match Claude's precedence: the explicit skip flag wins over a mode flag.
|
|
36
|
+
const permissionMode = values["dangerously-skip-permissions"] ? "bypassPermissions" : requestedMode;
|
|
37
|
+
const outputFormat = values["output-format"] ?? "text";
|
|
38
|
+
if (outputFormat !== "text" && outputFormat !== "json")
|
|
39
|
+
throw new CliUsageError("--output-format must be text or json");
|
|
40
|
+
for (const flag of ["model", "effort", "prompt"]) {
|
|
41
|
+
if (typeof values[flag] === "string" && !values[flag].trim())
|
|
42
|
+
throw new CliUsageError(`--${flag} cannot be empty`);
|
|
43
|
+
}
|
|
44
|
+
if (values.prompt !== undefined && positionals.length)
|
|
45
|
+
throw new CliUsageError("Use either a positional prompt or --prompt, not both");
|
|
46
|
+
const prompt = typeof values.prompt === "string" ? values.prompt : positionals.join(" ");
|
|
47
|
+
if (!print && (prompt || values["output-format"] !== undefined))
|
|
48
|
+
throw new CliUsageError("Use -p / --print or exec with a prompt or --output-format");
|
|
49
|
+
return { print, prompt, outputFormat, permissionMode, model: typeof values.model === "string" ? values.model : undefined,
|
|
50
|
+
effort: typeof values.effort === "string" ? values.effort : undefined, auto: values.auto === true,
|
|
51
|
+
resume: values.resume === true, help: values.help === true, version: values.version === true };
|
|
52
|
+
}
|
|
53
|
+
// CLI overrides live only in this process; never save them to user settings.
|
|
54
|
+
// Validate against the selected model/endpoint before any request is started.
|
|
55
|
+
export function applyCliOptions(options) {
|
|
56
|
+
const model = options.model ?? CONFIG.model;
|
|
57
|
+
if (options.effort !== undefined && options.effort !== "default") {
|
|
58
|
+
const menu = effortMenu(model);
|
|
59
|
+
if (!menu.values.includes(options.effort))
|
|
60
|
+
throw new CliUsageError(menu.values.length
|
|
61
|
+
? `Unsupported effort '${options.effort}' for ${model}. Choose: ${menu.values.join(", ")}` : menu.header);
|
|
62
|
+
}
|
|
63
|
+
CONFIG.model = model;
|
|
64
|
+
if (options.effort !== undefined)
|
|
65
|
+
setEffort(model, options.effort);
|
|
66
|
+
CONFIG.bypassPermissions = options.permissionMode === "bypassPermissions";
|
|
67
|
+
if (options.permissionMode !== undefined)
|
|
68
|
+
CONFIG.autoMode.enabled = options.permissionMode === "auto";
|
|
69
|
+
else if (options.auto)
|
|
70
|
+
CONFIG.autoMode.enabled = true;
|
|
71
|
+
}
|
|
72
|
+
// Match print-mode pipe composition: the instruction first, followed by stdin.
|
|
73
|
+
// Wait for EOF so slow producers aren't silently truncated. Never read a TTY.
|
|
74
|
+
export async function readPrintPrompt(prompt, input, isTTY) {
|
|
75
|
+
const chunks = [];
|
|
76
|
+
if (!isTTY)
|
|
77
|
+
for await (const chunk of input)
|
|
78
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
79
|
+
const text = [prompt, Buffer.concat(chunks).toString("utf8")].filter(Boolean).join("\n");
|
|
80
|
+
if (!text.trim())
|
|
81
|
+
throw new CliUsageError('Provide a prompt: mini-agent -p "<task>" or pipe text into mini-agent -p');
|
|
82
|
+
return text;
|
|
83
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
// Bound native helpers independently of the attachment limit. Always argv,
|
|
3
|
+
// never a shell command containing clipboard text, paths or user input.
|
|
4
|
+
const nativeCommand = (program, args) => new Promise((resolve, reject) => {
|
|
5
|
+
execFile(program, [...args], { encoding: "buffer", timeout: 8000, maxBuffer: 12 * 1024 * 1024 }, (error, stdout) => {
|
|
6
|
+
if (error)
|
|
7
|
+
reject(new Error(`Clipboard helper ${program} failed. Check clipboard access and that the helper is installed.`));
|
|
8
|
+
else
|
|
9
|
+
resolve(stdout);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
function decodeNative(data) {
|
|
13
|
+
const value = JSON.parse(data.toString("utf8"));
|
|
14
|
+
if (typeof value.image === "string" && value.image)
|
|
15
|
+
return { image: Buffer.from(value.image, "base64") };
|
|
16
|
+
return { text: typeof value.text === "string" ? value.text : "" };
|
|
17
|
+
}
|
|
18
|
+
const MAC_SCRIPT = `ObjC.import('AppKit');
|
|
19
|
+
var board = $.NSPasteboard.generalPasteboard;
|
|
20
|
+
if (board.isNil()) throw Error('Clipboard is unavailable');
|
|
21
|
+
var data = board.dataForType('public.png');
|
|
22
|
+
if (data.isNil()) {
|
|
23
|
+
var tiff = board.dataForType('public.tiff');
|
|
24
|
+
if (!tiff.isNil()) {
|
|
25
|
+
var bitmap = $.NSBitmapImageRep.alloc.initWithData(tiff);
|
|
26
|
+
if (!bitmap.isNil()) data = bitmap.representationUsingTypeProperties($.NSPNGFileType, $.NSDictionary.dictionary);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!data.isNil()) {
|
|
30
|
+
if (Number(data.length) > 5 * 1024 * 1024) throw Error('Image exceeds 5 MB');
|
|
31
|
+
JSON.stringify({image: ObjC.unwrap(data.base64EncodedStringWithOptions(0))});
|
|
32
|
+
} else {
|
|
33
|
+
var text = board.stringForType('public.utf8-plain-text');
|
|
34
|
+
JSON.stringify({text: text.isNil() ? '' : ObjC.unwrap(text)});
|
|
35
|
+
}`;
|
|
36
|
+
const WINDOWS_SCRIPT = `$ErrorActionPreference = 'Stop'
|
|
37
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
38
|
+
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
39
|
+
if ([System.Windows.Forms.Clipboard]::ContainsImage()) {
|
|
40
|
+
$image = [System.Windows.Forms.Clipboard]::GetImage()
|
|
41
|
+
$buffer = New-Object System.IO.MemoryStream
|
|
42
|
+
try {
|
|
43
|
+
$image.Save($buffer, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
44
|
+
if ($buffer.Length -gt 5MB) { throw 'Image exceeds 5 MB' }
|
|
45
|
+
@{ image = [Convert]::ToBase64String($buffer.ToArray()) } | ConvertTo-Json -Compress
|
|
46
|
+
} finally { $buffer.Dispose(); $image.Dispose() }
|
|
47
|
+
} else {
|
|
48
|
+
@{ text = [System.Windows.Forms.Clipboard]::GetText() } | ConvertTo-Json -Compress
|
|
49
|
+
}`;
|
|
50
|
+
class MacClipboard {
|
|
51
|
+
run;
|
|
52
|
+
constructor(run) {
|
|
53
|
+
this.run = run;
|
|
54
|
+
}
|
|
55
|
+
async read() { return decodeNative(await this.run("osascript", ["-l", "JavaScript", "-e", MAC_SCRIPT])); }
|
|
56
|
+
}
|
|
57
|
+
class WindowsClipboard {
|
|
58
|
+
run;
|
|
59
|
+
constructor(run) {
|
|
60
|
+
this.run = run;
|
|
61
|
+
}
|
|
62
|
+
async read() { return decodeNative(await this.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-STA", "-Command", WINDOWS_SCRIPT])); }
|
|
63
|
+
}
|
|
64
|
+
class LinuxClipboard {
|
|
65
|
+
run;
|
|
66
|
+
wayland;
|
|
67
|
+
constructor(run, wayland) {
|
|
68
|
+
this.run = run;
|
|
69
|
+
this.wayland = wayland;
|
|
70
|
+
}
|
|
71
|
+
async read() {
|
|
72
|
+
const program = this.wayland ? "wl-paste" : "xclip";
|
|
73
|
+
const types = (await this.run(program, this.wayland ? ["--list-types"] : ["-selection", "clipboard", "-o", "-t", "TARGETS"])).toString("utf8").split(/\r?\n/);
|
|
74
|
+
const imageType = ["image/png", "image/jpeg", "image/webp", "image/gif"].find((type) => types.includes(type));
|
|
75
|
+
const type = imageType ?? (this.wayland ? "text" : "UTF8_STRING");
|
|
76
|
+
const data = await this.run(program, this.wayland ? ["--no-newline", "--type", type] : ["-selection", "clipboard", "-o", "-t", type]);
|
|
77
|
+
return imageType ? { image: data } : { text: data.toString("utf8") };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function systemClipboard(platform = process.platform, env = process.env, run = nativeCommand) {
|
|
81
|
+
if (platform === "darwin")
|
|
82
|
+
return new MacClipboard(run);
|
|
83
|
+
if (platform === "win32")
|
|
84
|
+
return new WindowsClipboard(run);
|
|
85
|
+
if (platform === "linux")
|
|
86
|
+
return new LinuxClipboard(run, !!env.WAYLAND_DISPLAY);
|
|
87
|
+
return { read: async () => { throw new Error(`Clipboard paste is not supported on ${platform}.`); } };
|
|
88
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { effortMenu } from "./effort.js";
|
|
2
|
+
export const COMMANDS = [
|
|
3
|
+
{ value: "/model", description: "Switch the model for this session", children: true },
|
|
4
|
+
{ value: "/effort", description: "Choose this model's reasoning effort", children: true },
|
|
5
|
+
{ value: "/skills", description: "Browse and run available skills", children: true },
|
|
6
|
+
{ value: "/skill", description: "Run a skill by name", children: true },
|
|
7
|
+
{ value: "/help", description: "Show commands and keyboard shortcuts" },
|
|
8
|
+
{ value: "/clear", description: "Start a fresh conversation" },
|
|
9
|
+
{ value: "/compact", description: "Summarize conversation history" },
|
|
10
|
+
{ value: "/stats", description: "Show session event counts" },
|
|
11
|
+
{ value: "/memory", description: "Show remembered project facts" },
|
|
12
|
+
{ value: "/status", description: "Show session usage and account balance/cost", children: true },
|
|
13
|
+
{ value: "/cost", description: "Show tokens and estimated cost" },
|
|
14
|
+
{ value: "/mcp", description: "Manage MCP servers", children: true },
|
|
15
|
+
{ value: "/plan", description: "Toggle plan mode" },
|
|
16
|
+
{ value: "/auto", description: "Toggle automatic permission review", children: true },
|
|
17
|
+
{ value: "/todos", description: "Show the current task plan" },
|
|
18
|
+
{ value: "/bg", description: "Show background tasks" },
|
|
19
|
+
{ value: "/team", description: "Show teammates and status" },
|
|
20
|
+
{ value: "/tasks", description: "Show the shared task board" },
|
|
21
|
+
{ value: "/cron", description: "Show scheduled jobs" },
|
|
22
|
+
{ value: "/undo", description: "Revert the most recent file write" },
|
|
23
|
+
{ value: "/diff", description: "Show file changes this session" },
|
|
24
|
+
{ value: "/resume", description: "Choose a previous session" },
|
|
25
|
+
];
|
|
26
|
+
export function slashCompletions(input, ctx) {
|
|
27
|
+
if (!input.startsWith("/") || /[\r\n]/.test(input))
|
|
28
|
+
return null;
|
|
29
|
+
const [command, ...parts] = input.split(/\s+/);
|
|
30
|
+
const arg = parts.join(" ").toLowerCase();
|
|
31
|
+
const filter = (items, prefix = input) => items.filter((item) => item.value.toLowerCase().startsWith(prefix.toLowerCase()));
|
|
32
|
+
if (command === "/effort") {
|
|
33
|
+
const menu = effortMenu(ctx.model);
|
|
34
|
+
return { items: filter(menu.values.map((value, i) => ({ value: `/effort ${value}`, description: menu.labels[i] }))), hint: menu.header };
|
|
35
|
+
}
|
|
36
|
+
if (command === "/skills" || command === "/skill") {
|
|
37
|
+
return { items: ctx.skills.filter((s) => s.name.toLowerCase().startsWith(arg)).map((s) => ({ value: `/skill ${s.name}`, description: s.whenToUse || s.description })), hint: ctx.skills.length ? "Choose a skill to run" : "No skills found in project or user skill directories" };
|
|
38
|
+
}
|
|
39
|
+
if (command === "/model") {
|
|
40
|
+
const save = arg === "save" || arg.startsWith("save ");
|
|
41
|
+
const prefix = save ? "/model save " : "/model ";
|
|
42
|
+
const items = [...new Set([ctx.model, ...ctx.models])].sort().map((name) => ({ value: prefix + name, description: name === ctx.model ? "Current model" : "Switch model" }));
|
|
43
|
+
if (!save)
|
|
44
|
+
items.push({ value: "/model save", description: "Save a model as your default", children: true });
|
|
45
|
+
return { items: filter(items, save && arg === "save" ? prefix : input), hint: "Models from your endpoint; you can also type a model name" };
|
|
46
|
+
}
|
|
47
|
+
if (command === "/status") {
|
|
48
|
+
return { items: filter([
|
|
49
|
+
{ value: "/status", description: "Current endpoint account and session usage" },
|
|
50
|
+
{ value: "/status local", description: "Session only; no account API request" },
|
|
51
|
+
...["deepseek", "openai", "anthropic"].map((provider) => ({ value: `/status ${provider}`, description: `Query the official ${provider} account with its dedicated credential` })),
|
|
52
|
+
], input.trimEnd()) };
|
|
53
|
+
}
|
|
54
|
+
if (command === "/auto") {
|
|
55
|
+
return { items: filter([
|
|
56
|
+
{ value: "/auto", description: "Toggle auto mode" },
|
|
57
|
+
...["on", "off", "status"].map((v) => ({ value: `/auto debug ${v}`, description: `${v === "status" ? "Inspect" : v === "on" ? "Enable" : "Disable"} reviewer request logging` })),
|
|
58
|
+
], input.trimEnd()) };
|
|
59
|
+
}
|
|
60
|
+
if (command === "/mcp") {
|
|
61
|
+
const items = [{ value: "/mcp", description: "Open MCP server menu" }, ...["reconnect", "auth", "enable", "disable"].flatMap((action) => ctx.servers.map((name) => ({ value: `/mcp ${action} ${name}`, description: `${action} ${name}` })))];
|
|
62
|
+
return { items: filter(items, input.trimEnd()) };
|
|
63
|
+
}
|
|
64
|
+
return { items: filter(COMMANDS), hint: "Commands" };
|
|
65
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -52,6 +52,7 @@ export const CONFIG = {
|
|
|
52
52
|
process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
|
|
53
53
|
"", // empty = missing; requireApiKey() turns that into a helpful error
|
|
54
54
|
model: process.env.MINI_AGENT_MODEL || projectSettings.model || globalSettings.model || "deepseek-chat", // must support function calling
|
|
55
|
+
effortProfiles: { ...(globalSettings.effortProfiles ?? {}), ...(projectSettings.effortProfiles ?? {}) },
|
|
55
56
|
// Sub-agents (the task tool) can run on a DIFFERENT model than the orchestrator:
|
|
56
57
|
// a cheap/fast one for grunt work (reading many files, broad search), or a
|
|
57
58
|
// strong one as an "advisor" to double-check. undefined → use the main model.
|
|
@@ -60,6 +61,8 @@ export const CONFIG = {
|
|
|
60
61
|
projectSettings.contextWindow || // then files
|
|
61
62
|
globalSettings.contextWindow ||
|
|
62
63
|
1_048_565, // DeepSeek's real window, learned from an actual API error
|
|
64
|
+
// Explicit CLI opt-in only: never enable unattended bypass from saved settings.
|
|
65
|
+
bypassPermissions: false,
|
|
63
66
|
permissions: {
|
|
64
67
|
// Both layers apply: global rules + project rules. Mutable on purpose —
|
|
65
68
|
// the test suite injects rules here instead of writing temp files.
|
|
@@ -83,6 +86,11 @@ export const CONFIG = {
|
|
|
83
86
|
enabled: projectSettings.judge?.enabled ?? globalSettings.judge?.enabled ?? false,
|
|
84
87
|
model: projectSettings.judge?.model || globalSettings.judge?.model || undefined, // undefined → use the main model
|
|
85
88
|
},
|
|
89
|
+
autoMode: {
|
|
90
|
+
policy: (process.env.MINI_AGENT_AUTO_POLICY ?? projectSettings.autoMode?.policy ?? globalSettings.autoMode?.policy ?? "scores") === "rules" ? "rules" : "scores",
|
|
91
|
+
enabled: process.env.MINI_AGENT_AUTO_MODE !== undefined ? process.env.MINI_AGENT_AUTO_MODE === "1" : (projectSettings.autoMode?.enabled ?? globalSettings.autoMode?.enabled ?? false),
|
|
92
|
+
model: projectSettings.autoMode?.model || globalSettings.autoMode?.model || "jev-1.13.0",
|
|
93
|
+
},
|
|
86
94
|
// Token prices for the /cost estimate (defaults applied in cost.ts). Project
|
|
87
95
|
// overrides global; either may set just the fields it cares about.
|
|
88
96
|
pricing: { ...(globalSettings.pricing ?? {}), ...(projectSettings.pricing ?? {}) },
|
package/dist/context.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { effortMessages } from "./effort.js";
|
|
1
2
|
import fs from "node:fs"; // re-reading files from disk during recovery
|
|
2
3
|
import chalk from "chalk"; // status lines
|
|
3
4
|
import { CONFIG } from "./config.js"; // the resolved context window (env > settings files > default)
|
|
@@ -23,11 +24,16 @@ export function estimateTokens(text, dense = false) {
|
|
|
23
24
|
export function estimateHistoryTokens(messages) {
|
|
24
25
|
let total = 0; // running sum
|
|
25
26
|
for (const m of messages) {
|
|
26
|
-
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""); // content can be structured — serialize it
|
|
27
27
|
const isDense = m.role === "tool"; // tool results are JSON-ish → denser tokens
|
|
28
|
-
|
|
28
|
+
// Base64 bytes are transport, not text tokens. Image tokenization differs
|
|
29
|
+
// by model/resolution; reserve a budget per image, then use API usage for
|
|
30
|
+
// actual accounting and reactive compaction for provider context limits.
|
|
31
|
+
total += Array.isArray(m.content) ? m.content.reduce((sum, part) => sum + (part.type === "image_url" ? 4096 : estimateTokens(JSON.stringify(part), isDense)), 0) + 8
|
|
32
|
+
: estimateTokens(m.content ?? "", isDense) + 8;
|
|
29
33
|
if ("tool_calls" in m && m.tool_calls)
|
|
30
34
|
total += estimateTokens(JSON.stringify(m.tool_calls), true); // count the call arguments too
|
|
35
|
+
if ("reasoning_content" in m && typeof m.reasoning_content === "string")
|
|
36
|
+
total += estimateTokens(m.reasoning_content);
|
|
31
37
|
}
|
|
32
38
|
return total;
|
|
33
39
|
}
|
|
@@ -86,7 +92,7 @@ log = (s) => console.log(s)) {
|
|
|
86
92
|
log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
|
|
87
93
|
const res = await client.chat.completions.create({
|
|
88
94
|
model, // same model — no need for a fancier one to summarize
|
|
89
|
-
messages: [...messages, { role: "user", content: SUMMARY_PROMPT }], // full history + the summary instruction
|
|
95
|
+
messages: [...effortMessages(model, messages), { role: "user", content: SUMMARY_PROMPT }], // full history + the summary instruction
|
|
90
96
|
// Deliberately NO `tools` parameter: with no tools declared, the API
|
|
91
97
|
// cannot accept a tool call — that is the hard guarantee. The CRITICAL
|
|
92
98
|
// lines in the prompt are the soft second layer of the same defense.
|
package/dist/cost.js
CHANGED
|
@@ -79,6 +79,10 @@ export class CostMeter {
|
|
|
79
79
|
outputTokens() {
|
|
80
80
|
return this.total.output;
|
|
81
81
|
}
|
|
82
|
+
// Structured values for status cards; callers cannot mutate the meter.
|
|
83
|
+
snapshot() {
|
|
84
|
+
return { ...this.total, calls: this.calls, cost: this.cost() };
|
|
85
|
+
}
|
|
82
86
|
// The /cost report. Estimated, local-only.
|
|
83
87
|
report() {
|
|
84
88
|
const t = this.total;
|