@hncode/hncode 0.5.0 → 0.5.2
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/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +1 -1
- package/src/agent-task.js +12 -1
- package/src/agent.js +115 -35
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +42 -6
- package/src/colors.js +478 -387
- package/src/config.js +38 -11
- package/src/context-bar.js +46 -5
- package/src/doctor.js +214 -11
- package/src/git.js +35 -4
- package/src/llm.js +6 -0
- package/src/memory-profile.js +247 -0
- package/src/permissions.js +29 -7
- package/src/registry-browser.js +21 -6
- package/src/row-cache.js +147 -142
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-hub.js +1 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +304 -0
- package/src/shell-safety.js +95 -6
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +139 -0
- package/src/team/runner.js +347 -0
- package/src/team/spawn.js +247 -0
- package/src/team/store.js +145 -0
- package/src/team/tasks.js +164 -0
- package/src/team/team.js +311 -0
- package/src/team-panel.js +226 -0
- package/src/term.js +27 -0
- package/src/tool-result.js +279 -236
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +53 -3
- package/src/tools/edit.js +591 -436
- package/src/tools/git.js +15 -3
- package/src/tools/index.js +2 -0
- package/src/tools/team.js +398 -0
- package/src/tools/web-search.js +88 -75
- package/src/tui.js +6187 -4780
- package/src/update.js +285 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
- package/src/web.js +3 -1
package/src/config.js
CHANGED
|
@@ -388,6 +388,14 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
388
388
|
// "use the built-in SYSTEM_PROMPT". Read from config.toml so it persists
|
|
389
389
|
// across restarts.
|
|
390
390
|
systemPrompt: process.env.HNCODE_SYSTEM_PROMPT || root.system_prompt || '',
|
|
391
|
+
// The prompt knobs added alongside it, all editable from /settings › Prompt. Separate
|
|
392
|
+
// keys rather than one blob, because they mean different things: `append` keeps the
|
|
393
|
+
// built-in prompt and adds to it, `personal` is the user's own standing note, and the
|
|
394
|
+
// per-mode ones only apply while that mode is on.
|
|
395
|
+
appendSystemPrompt: process.env.HNCODE_APPEND_SYSTEM_PROMPT || root.append_system_prompt || '',
|
|
396
|
+
planInstructions: root.plan_instructions || '',
|
|
397
|
+
focusInstructions: root.focus_instructions || '',
|
|
398
|
+
swarmInstructions: root.swarm_instructions || '',
|
|
391
399
|
// ---- Web UI daemon -------------------------------------------------------
|
|
392
400
|
// One daemon serves EVERY hncode session on this machine: it holds the fixed
|
|
393
401
|
// port, proxies each session's requests to that session's own loopback
|
|
@@ -1146,24 +1154,43 @@ export function effortOptions(cfg, modelKeyName) {
|
|
|
1146
1154
|
return ['off', 'on'];
|
|
1147
1155
|
}
|
|
1148
1156
|
|
|
1157
|
+
// Token budget for a NAMED grade, on a protocol that wants a number rather than a word.
|
|
1158
|
+
//
|
|
1159
|
+
// The grades the catalog declares are low/medium/high, and a user may also write their own
|
|
1160
|
+
// in config.toml (`efforts = ["xhigh"]`). A grade with no entry here has no honest budget to
|
|
1161
|
+
// send, so the field is omitted — the previous code fell back to 8000, which silently gave
|
|
1162
|
+
// every unknown grade the same strength as `on` and `medium`.
|
|
1163
|
+
const EFFORT_BUDGETS = { low: 2000, medium: 8000, high: 16000, max: 32000 };
|
|
1164
|
+
|
|
1149
1165
|
// Map a chosen effort to the wire value for the active protocol.
|
|
1166
|
+
//
|
|
1167
|
+
// `on` means "thinking on", NOT "medium". The two are different requests: a model may
|
|
1168
|
+
// support a plain toggle without any graded control, in which case there is no grade to
|
|
1169
|
+
// send and inventing one (`medium`) can be rejected outright by the provider. `on` therefore
|
|
1170
|
+
// sends the protocol's plain enable, or nothing at all.
|
|
1150
1171
|
export function effortWire(cfg, effort) {
|
|
1151
1172
|
if (!effort || effort === 'off') return null;
|
|
1173
|
+
const named = Object.prototype.hasOwnProperty.call(EFFORT_BUDGETS, effort);
|
|
1174
|
+
|
|
1152
1175
|
if (cfg.protocol === 'anthropic') {
|
|
1153
|
-
|
|
1154
|
-
|
|
1176
|
+
// `thinking: { type: 'enabled' }` IS the plain toggle; `budget_tokens` is optional and
|
|
1177
|
+
// only meaningful for a named grade. Omitting it lets the provider pick its own budget,
|
|
1178
|
+
// which is what "on" asked for.
|
|
1179
|
+
return named
|
|
1180
|
+
? { thinking: { type: 'enabled', budget_tokens: EFFORT_BUDGETS[effort] } }
|
|
1181
|
+
: { thinking: { type: 'enabled' } };
|
|
1155
1182
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
return { reasoning_effort: grade };
|
|
1183
|
+
|
|
1184
|
+
// Responses API and the OpenAI-compatible shape both take a GRADE word, and neither has
|
|
1185
|
+
// a value meaning "on". So a named grade passes through, and `on` sends nothing — the
|
|
1186
|
+
// request carries whatever the provider does by default for that model, rather than a
|
|
1187
|
+
// grade the model may not accept.
|
|
1188
|
+
if (!named) return {};
|
|
1189
|
+
if (cfg.protocol === 'responses') return { reasoning: { effort } };
|
|
1190
|
+
return { reasoning_effort: effort };
|
|
1165
1191
|
}
|
|
1166
1192
|
|
|
1193
|
+
|
|
1167
1194
|
function toTomlLine(k, v) {
|
|
1168
1195
|
if (typeof v === 'string') return `${k} = "${v.replace(/"/g, '\\"')}"`;
|
|
1169
1196
|
if (typeof v === 'boolean') return `${k} = ${v}`;
|
package/src/context-bar.js
CHANGED
|
@@ -228,10 +228,51 @@ export function formatReadout(used, window, freeCols) {
|
|
|
228
228
|
return '';
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
/**
|
|
231
|
+
/**
|
|
232
|
+
* A token count as a compact string: `1200` -> `1.2k`, `97000` -> `97k`, `3400000` -> `3.4M`.
|
|
233
|
+
*
|
|
234
|
+
* Deliberately the SAME shape `fmtTokensShort` produces for the working row, because both appear
|
|
235
|
+
* in one frame — the context bar and the live turn counter would otherwise disagree about the same
|
|
236
|
+
* quantity. Two rules:
|
|
237
|
+
*
|
|
238
|
+
* * one decimal while it carries information, dropped when it is a zero (`97000` is `97k`, not
|
|
239
|
+
* `97.0k` — the trailing `.0` is three characters saying nothing);
|
|
240
|
+
* * K/M/B/T suffixes, with only the kilo lower-case, so `3.4M` here and `3.4M` on the working row
|
|
241
|
+
* read as one vocabulary.
|
|
242
|
+
*
|
|
243
|
+
* The previous version kept `.0` and disagreed with the working row on units entirely: it divided
|
|
244
|
+
* by 1024 where the row divided by 1000, so a 97k context read `94.7k` beside a `97k` turn.
|
|
245
|
+
*/
|
|
232
246
|
export function compactTokens(n) {
|
|
233
|
-
const v = Number(n) || 0;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
247
|
+
const v = Math.max(0, Math.floor(Number(n) || 0));
|
|
248
|
+
const units = [
|
|
249
|
+
{ at: 1e12, suffix: 'T' },
|
|
250
|
+
{ at: 1e9, suffix: 'B' },
|
|
251
|
+
{ at: 1e6, suffix: 'M' },
|
|
252
|
+
{ at: 1e3, suffix: 'k' },
|
|
253
|
+
];
|
|
254
|
+
for (let i = 0; i < units.length; i++) {
|
|
255
|
+
const u = units[i];
|
|
256
|
+
if (v < u.at) continue;
|
|
257
|
+
// The identical scaling rule the working row and the /ctx readout use: one decimal below 100,
|
|
258
|
+
// whole numbers above. Three formatters render token counts into one frame, so a single rule is
|
|
259
|
+
// the only way `262k` cannot appear beside `262.1k` for the same quantity.
|
|
260
|
+
const shown = scaleUnit(v / u.at);
|
|
261
|
+
// Rounding can reach the next unit (`999999` is `1000k`, which is `1m`), and showing `1000k`
|
|
262
|
+
// makes the reader convert a unit themselves.
|
|
263
|
+
if (Number(shown) >= 1000 && i > 0) return `${scaleUnit(v / units[i - 1].at)}${units[i - 1].suffix}`;
|
|
264
|
+
return `${shown}${u.suffix}`;
|
|
265
|
+
}
|
|
266
|
+
return String(v);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* One unit's worth of a count: `1.2`, `97`, `262`.
|
|
271
|
+
*
|
|
272
|
+
* Duplicated from tui.js rather than imported, because `context-bar.js` must stay a leaf module the
|
|
273
|
+
* TUI can import without a cycle. The two are pinned together by test/context-bar.test.mjs and
|
|
274
|
+
* test/working-tokens.test.mjs, which assert the same table of values through both.
|
|
275
|
+
*/
|
|
276
|
+
function scaleUnit(v) {
|
|
277
|
+
return v < 100 ? v.toFixed(1).replace(/\.0$/, '') : String(Math.round(v));
|
|
237
278
|
}
|
package/src/doctor.js
CHANGED
|
@@ -77,8 +77,10 @@ function checkConfig(cfg, files) {
|
|
|
77
77
|
return out;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/** The session directory. A store that cannot be listed is a store whose work is invisible.
|
|
81
|
-
|
|
80
|
+
/** The session directory. A store that cannot be listed is a store whose work is invisible.
|
|
81
|
+
*
|
|
82
|
+
* Async because it profiles the largest session — see the memory breakdown below. */
|
|
83
|
+
async function checkSessions(files, sessionsDir, limit = 4) {
|
|
82
84
|
const out = [];
|
|
83
85
|
if (!fs.existsSync(sessionsDir)) {
|
|
84
86
|
return [check('session store', 'warn', `${sessionsDir} does not exist yet`,
|
|
@@ -110,14 +112,17 @@ function checkSessions(files, sessionsDir, limit = 4) {
|
|
|
110
112
|
// actually hit: past ~512 MB `JSON.stringify` throws, and every save of that session
|
|
111
113
|
// then fails until the transcript is trimmed.
|
|
112
114
|
const V8_LIMIT = 512 * 1024 * 1024;
|
|
113
|
-
|
|
115
|
+
// Sizes of every session, so the memory profile can pick the largest regardless of how
|
|
116
|
+
// large it is. The `big` list below is filtered to the ones worth WARNING about; the
|
|
117
|
+
// profiler wants the largest whatever its size, because "this session holds 0.66 MB of
|
|
118
|
+
// tool arguments" is useful at 4 MB and not only at 500.
|
|
119
|
+
const sizes = [];
|
|
114
120
|
for (const f of names) {
|
|
115
|
-
try {
|
|
116
|
-
const size = fs.statSync(path.join(sessionsDir, f)).size;
|
|
117
|
-
if (size > 8 * 1024 * 1024) big.push({ f, size });
|
|
118
|
-
} catch { /* raced away */ }
|
|
121
|
+
try { sizes.push({ f, size: fs.statSync(path.join(sessionsDir, f)).size }); } catch { /* raced away */ }
|
|
119
122
|
}
|
|
120
|
-
|
|
123
|
+
sizes.sort((a, b) => b.size - a.size);
|
|
124
|
+
const big = sizes.filter((x) => x.size > 8 * 1024 * 1024);
|
|
125
|
+
|
|
121
126
|
for (const b of big.slice(0, limit)) {
|
|
122
127
|
const mb = (b.size / 1048576).toFixed(1);
|
|
123
128
|
const sev = b.size > V8_LIMIT * 0.75 ? 'error' : 'warn';
|
|
@@ -127,6 +132,38 @@ function checkSessions(files, sessionsDir, limit = 4) {
|
|
|
127
132
|
: 'Large; loading and saving this session will be slow.'));
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
// WHERE THE BYTES ARE in the biggest session, as opposed to how big the file is.
|
|
136
|
+
//
|
|
137
|
+
// A file size says nothing actionable: the same 2.5 MB can be a conversation or a
|
|
138
|
+
// runaway transcript, and only one of those is worth fixing. The breakdown also explains
|
|
139
|
+
// the file being LARGER than the content it holds — measured on a real session, the
|
|
140
|
+
// serialised JSON was 2.29 MB while text + tool results + tool arguments summed to
|
|
141
|
+
// 1.27 MB, so structure and escaping are as expensive as the text.
|
|
142
|
+
//
|
|
143
|
+
// Skipped below 1 MB: loading and measuring a session to learn it is small is itself
|
|
144
|
+
// work, and the finding would be "nothing to see". Deliberately NOT gated on `big` —
|
|
145
|
+
// that list only holds sessions big enough to warn about, and the breakdown is most
|
|
146
|
+
// useful on an ordinary one: it is where "half your memory is tool arguments" shows up.
|
|
147
|
+
if (sizes.length && sizes[0].size > 1024 * 1024) {
|
|
148
|
+
const top = sizes[0];
|
|
149
|
+
try {
|
|
150
|
+
const { loadSession } = await import('./session.js');
|
|
151
|
+
const { profileMemory, memoryHeadline } = await import('./memory-profile.js');
|
|
152
|
+
const loaded = loadSession(top.f.replace(/\.json$/, ''));
|
|
153
|
+
if (loaded) {
|
|
154
|
+
const profile = profileMemory({ session: loaded });
|
|
155
|
+
out.push(check('largest session memory', 'info',
|
|
156
|
+
`${memoryHeadline(profile)} (${top.f})`,
|
|
157
|
+
'Text, tool results and tool ARGUMENTS are counted separately; run /memory-profile for the full breakdown.'));
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
out.push(check('largest session memory', 'warn', `could not profile ${top.f}: ${e.message}`,
|
|
161
|
+
'The file was readable by size but would not parse.'));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Leftover temp files from an interrupted save.
|
|
166
|
+
|
|
130
167
|
// Leftover temp files from an interrupted save.
|
|
131
168
|
let tmp = [];
|
|
132
169
|
try { tmp = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.tmp')); } catch { /* listed above */ }
|
|
@@ -213,6 +250,165 @@ function checkExtensions(files) {
|
|
|
213
250
|
return out;
|
|
214
251
|
}
|
|
215
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Free space where the sessions live.
|
|
255
|
+
*
|
|
256
|
+
* A store that fills up is a store that loses work, and the failure is not obviously about
|
|
257
|
+
* disk: the write fails, the turn is lost, and the error text says ENOENT or EACCES. The
|
|
258
|
+
* session directory held 608 MB on this machine at one point, which is the shape that walks
|
|
259
|
+
* a disk to full without anyone noticing.
|
|
260
|
+
*
|
|
261
|
+
* `statfs` is used where the platform has it (Node 18.15+). Where it does not, the check is
|
|
262
|
+
* SKIPPED rather than guessed — a wrong number here is worse than no number.
|
|
263
|
+
*/
|
|
264
|
+
function checkDisk(dir) {
|
|
265
|
+
if (typeof fs.statfsSync !== 'function') {
|
|
266
|
+
return [check('disk space', 'info', 'not measurable on this Node/platform', '')];
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const st = fs.statfsSync(dir);
|
|
270
|
+
const freeBytes = Number(st.bavail) * Number(st.bsize);
|
|
271
|
+
const totalBytes = Number(st.blocks) * Number(st.bsize);
|
|
272
|
+
const freeGb = freeBytes / 1024 ** 3;
|
|
273
|
+
const totalGb = totalBytes / 1024 ** 3;
|
|
274
|
+
// Thresholds are deliberately far apart: below 1 GB is an error (a long session can
|
|
275
|
+
// write hundreds of megabytes), below 5 GB is a warning.
|
|
276
|
+
const sev = freeGb < 1 ? 'error' : freeGb < 5 ? 'warn' : 'ok';
|
|
277
|
+
return [check('disk space', sev, `${freeGb.toFixed(1)} GB free of ${totalGb.toFixed(0)} GB`,
|
|
278
|
+
sev === 'ok' ? ''
|
|
279
|
+
: 'Saving a session writes the whole transcript, which has reached hundreds of MB. Free space, or point HNCODE_SESSIONS_DIR at another volume.')];
|
|
280
|
+
} catch (e) {
|
|
281
|
+
return [check('disk space', 'info', `could not measure: ${e.message}`, '')];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The workspace's git state.
|
|
287
|
+
*
|
|
288
|
+
* Two things here actually break work: a missing `user.name`/`user.email` makes every
|
|
289
|
+
* commit fail, and an unfinished merge or rebase means the tree is not what the agent
|
|
290
|
+
* thinks it is. Both are cheap to check and neither surfaced anywhere before.
|
|
291
|
+
*/
|
|
292
|
+
function checkGit(workspace, cp) {
|
|
293
|
+
if (!workspace) return [];
|
|
294
|
+
if (!cp) return [check('git', 'info', 'the git module is unavailable', '')];
|
|
295
|
+
const run = (args) => {
|
|
296
|
+
try {
|
|
297
|
+
const r = cp.spawnSync('git', args, { cwd: workspace, encoding: 'utf8', timeout: 5000, windowsHide: true });
|
|
298
|
+
return r.status === 0 ? String(r.stdout || '').trim() : null;
|
|
299
|
+
} catch { return null; }
|
|
300
|
+
};
|
|
301
|
+
// A workspace that is not a repo is normal, and says nothing about health.
|
|
302
|
+
const top = run(['rev-parse', '--show-toplevel']);
|
|
303
|
+
if (!top) return [check('git', 'info', 'not a git repository', '')];
|
|
304
|
+
|
|
305
|
+
const out = [];
|
|
306
|
+
const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
307
|
+
out.push(check('git', 'ok', `${branch || '(detached)'} in ${top}`));
|
|
308
|
+
|
|
309
|
+
// Because `run` pins cwd to the workspace, `config --get` reports the identity that a
|
|
310
|
+
// commit HERE would actually use — not one inherited from an unrelated directory.
|
|
311
|
+
// A value that IS set is reported too: only a row in the report distinguishes
|
|
312
|
+
// "configured" from "the check never ran", and silence reads as success.
|
|
313
|
+
for (const key of ['user.name', 'user.email']) {
|
|
314
|
+
const value = run(['config', '--get', key]);
|
|
315
|
+
if (value) out.push(check(`git ${key}`, 'ok', value));
|
|
316
|
+
else {
|
|
317
|
+
out.push(check(`git ${key}`, 'warn', 'not set',
|
|
318
|
+
`Every commit fails without it. Run: git config --global ${key} "…"`));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// A half-finished operation changes what the agent is looking at.
|
|
322
|
+
const gitDir = run(['rev-parse', '--git-dir']);
|
|
323
|
+
if (gitDir) {
|
|
324
|
+
const abs = path.isAbsolute(gitDir) ? gitDir : path.join(workspace, gitDir);
|
|
325
|
+
for (const [file, what] of [['MERGE_HEAD', 'a merge'], ['REBASE_HEAD', 'a rebase'], ['CHERRY_PICK_HEAD', 'a cherry-pick']]) {
|
|
326
|
+
if (fs.existsSync(path.join(abs, file))) {
|
|
327
|
+
out.push(check('git in progress', 'warn', `${what} is unfinished`,
|
|
328
|
+
'The working tree is mid-operation; resolve it before trusting a diff.'));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A LIVE end-to-end probe: can this configuration actually complete a turn?
|
|
337
|
+
*
|
|
338
|
+
* This is the check a static report cannot make. Everything above reads files; a key that
|
|
339
|
+
* exists and an endpoint that parses still say nothing about whether a request SUCCEEDS, and
|
|
340
|
+
* the failures that matter — a revoked key, a wrong base URL, a model the account cannot
|
|
341
|
+
* reach, a provider that rejects `reasoning_effort` — are all invisible until a real request
|
|
342
|
+
* is made.
|
|
343
|
+
*
|
|
344
|
+
* It costs one request and a handful of tokens, so it is OPT-IN (`/doctor --probe`) and never
|
|
345
|
+
* part of a plain run. Modelled on jcode's provider-doctor, whose AUTH_CREDENTIAL_LOADED,
|
|
346
|
+
* NON_STREAMING_CHAT_COMPLETION and STREAMING_CHAT_COMPLETION checks do the same thing.
|
|
347
|
+
*
|
|
348
|
+
* The steps are reported as they happen, so a failure names the step that broke rather than
|
|
349
|
+
* only saying the whole thing did not work.
|
|
350
|
+
*/
|
|
351
|
+
async function probeProvider(cfg, input = {}) {
|
|
352
|
+
const out = [];
|
|
353
|
+
const send = input.probeRequest; // injected by a test; see the note below
|
|
354
|
+
if (!cfg.provider || !cfg.model) {
|
|
355
|
+
return [check('probe', 'info', 'skipped: no provider/model configured', '')];
|
|
356
|
+
}
|
|
357
|
+
if (!cfg.endpoint) {
|
|
358
|
+
return [check('probe', 'info', 'skipped: no endpoint', '')];
|
|
359
|
+
}
|
|
360
|
+
// A test supplies its own transport. Without one, the real LLM class is used — lazily, so
|
|
361
|
+
// a plain /doctor never pays to import the provider stack.
|
|
362
|
+
const doRequest = send || (async () => {
|
|
363
|
+
const { LLM } = await import('./llm.js');
|
|
364
|
+
const llm = new LLM(cfg);
|
|
365
|
+
let text = '';
|
|
366
|
+
let sawError = null;
|
|
367
|
+
let usage = null;
|
|
368
|
+
await llm.request(
|
|
369
|
+
[{ role: 'user', content: 'Reply with the single word: ok' }],
|
|
370
|
+
(e) => {
|
|
371
|
+
if (e.type === 'data') text += e.text;
|
|
372
|
+
else if (e.type === 'error') sawError = e.error;
|
|
373
|
+
else if (e.type === 'usage') usage = e.usage;
|
|
374
|
+
},
|
|
375
|
+
// One turn, no tools, no streaming: the smallest request that proves the pipeline.
|
|
376
|
+
{ noTools: true, streaming: false },
|
|
377
|
+
);
|
|
378
|
+
return { text, error: sawError, usage };
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const t0 = Date.now();
|
|
382
|
+
let res;
|
|
383
|
+
try {
|
|
384
|
+
res = await doRequest();
|
|
385
|
+
} catch (e) {
|
|
386
|
+
return [check('probe', 'error', `request threw: ${(e && e.message) || e}`,
|
|
387
|
+
'The configuration parses but a request does not complete. Check the key, the endpoint and the network.')];
|
|
388
|
+
}
|
|
389
|
+
const ms = Date.now() - t0;
|
|
390
|
+
|
|
391
|
+
if (res && res.error) {
|
|
392
|
+
return [check('probe', 'error', `provider error: ${String(res.error).slice(0, 160)}`,
|
|
393
|
+
'The key may be revoked, the model unavailable, or the provider rejecting a field in the request.')];
|
|
394
|
+
}
|
|
395
|
+
const text = String((res && res.text) || '').trim();
|
|
396
|
+
if (!text) {
|
|
397
|
+
// A 200 with no text is the failure mode that looks like success: a reasoning model can
|
|
398
|
+
// spend its whole budget on `reasoning_content` and return an empty `content`.
|
|
399
|
+
return [check('probe', 'error', `empty reply in ${ms} ms`,
|
|
400
|
+
'The request completed but returned no text. If this model always reasons, it may be spending the whole output budget on reasoning.')];
|
|
401
|
+
}
|
|
402
|
+
// `usage` comes off the RESULT, not out of the closure that built it — reading the local
|
|
403
|
+
// inside `doRequest` from here was a ReferenceError on every successful probe.
|
|
404
|
+
const usage = res && res.usage;
|
|
405
|
+
out.push(check('probe', 'ok', `replied "${text.slice(0, 20)}" in ${ms} ms`,
|
|
406
|
+
usage ? `provider reported ${JSON.stringify(usage)}` : ''));
|
|
407
|
+
return out;
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
216
412
|
/** The terminal. An unknown size or a dumb TERM explains a broken-looking UI. */
|
|
217
413
|
function checkTerminal(env, dims) {
|
|
218
414
|
const out = [];
|
|
@@ -224,6 +420,8 @@ function checkTerminal(env, dims) {
|
|
|
224
420
|
} else {
|
|
225
421
|
out.push(check('TERM', 'ok', `${term}${env.COLORTERM ? ` / ${env.COLORTERM}` : ''}`));
|
|
226
422
|
}
|
|
423
|
+
// Named distinctly from the TERM check: both used to be called "TERM", so a report with a
|
|
424
|
+
// small terminal showed the same name twice and read as a duplicated finding.
|
|
227
425
|
if (dims && (dims.cols < 60 || dims.rows < 15)) {
|
|
228
426
|
out.push(check('terminal size', 'warn', `${dims.cols}x${dims.rows}`,
|
|
229
427
|
'Below 60x15 the layout cannot fit; widen the window.'));
|
|
@@ -257,7 +455,7 @@ function checkRuntime(node, platform, dirs) {
|
|
|
257
455
|
* @param {string} [input.node] process.version, for tests
|
|
258
456
|
* @returns {Array<{name:string,severity:string,detail:string,fix:string}>}
|
|
259
457
|
*/
|
|
260
|
-
export function runDoctor(input = {}) {
|
|
458
|
+
export async function runDoctor(input = {}) {
|
|
261
459
|
const cfg = input.cfg || {};
|
|
262
460
|
const env = input.env || process.env;
|
|
263
461
|
const workspace = input.workspace || cfg.workspace || process.cwd();
|
|
@@ -278,13 +476,18 @@ export function runDoctor(input = {}) {
|
|
|
278
476
|
}),
|
|
279
477
|
...checkConfig(cfg, files),
|
|
280
478
|
...checkWorkspace(workspace),
|
|
281
|
-
...checkSessions(files, sessionsDir),
|
|
479
|
+
...(await checkSessions(files, sessionsDir)),
|
|
282
480
|
...checkErrorLog(files),
|
|
283
481
|
...checkMcp(input.mcpServers, input.mcpConnections),
|
|
284
482
|
...checkHooks(files, workspace),
|
|
285
483
|
...checkExtensions(files),
|
|
286
|
-
...
|
|
484
|
+
...checkDisk(sessionsDir),
|
|
485
|
+
...checkGit(workspace, input.childProcess),
|
|
486
|
+
...checkTerminal(env, input.dims),
|
|
287
487
|
];
|
|
488
|
+
// The live probe is OPT-IN: it spends a request and a few tokens, so it must never run
|
|
489
|
+
// as part of a plain /doctor.
|
|
490
|
+
if (input.probe) results.push(...await probeProvider(cfg, input));
|
|
288
491
|
return results;
|
|
289
492
|
}
|
|
290
493
|
|
package/src/git.js
CHANGED
|
@@ -33,6 +33,10 @@ const GIT_ENV = [
|
|
|
33
33
|
|
|
34
34
|
// Run git with args in `cwd`. Returns { ok, stdout, stderr, code, error }.
|
|
35
35
|
// Never throws: a missing git binary or a non-repo is data, not an exception.
|
|
36
|
+
//
|
|
37
|
+
// SYNCHRONOUS, and therefore only for the queries made at STARTUP (the status bar's branch and
|
|
38
|
+
// sha). It blocks the event loop, so an AbortSignal cannot fire during it — use `runGitAsync` for
|
|
39
|
+
// anything a tool call reaches.
|
|
36
40
|
export function runGit(args, cwd, opts = {}) {
|
|
37
41
|
const timeout = opts.timeoutMs || 30_000;
|
|
38
42
|
try {
|
|
@@ -53,7 +57,6 @@ export function runGit(args, cwd, opts = {}) {
|
|
|
53
57
|
return { ok: false, stdout: '', stderr: '', code: null, error: e.message };
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
|
-
|
|
57
60
|
export function isRepo(cwd) {
|
|
58
61
|
const r = runGit(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
59
62
|
return r.ok && r.stdout.trim() === 'true';
|
|
@@ -436,10 +439,20 @@ export function rebase(cwd, onto) {
|
|
|
436
439
|
// The async form below runs the same command off the main thread, so the frame
|
|
437
440
|
// draws immediately and the badge fills in when the data arrives.
|
|
438
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Run git without blocking the event loop.
|
|
444
|
+
*
|
|
445
|
+
* `opts.signal` makes the command interruptible, which is the whole reason a tool call must use this
|
|
446
|
+
* rather than `runGit`: a synchronous `spawnSync` holds the event loop, so an AbortSignal can never
|
|
447
|
+
* fire during it — measured, `spawnSync` accepts a `signal` option and ignores it, waiting out the
|
|
448
|
+
* full timeout. With Esc interrupting a turn, `git status` or `git log` on a large repository would
|
|
449
|
+
* otherwise freeze the TUI for up to the timeout with no key able to reach it.
|
|
450
|
+
*/
|
|
439
451
|
export function runGitAsync(args, cwd, opts = {}) {
|
|
440
452
|
return new Promise((resolve) => {
|
|
441
453
|
let child;
|
|
442
454
|
const timeout = opts.timeoutMs || 30_000;
|
|
455
|
+
const signal = opts.signal;
|
|
443
456
|
try {
|
|
444
457
|
child = cp.spawn('git', [...GIT_ENV, ...args], {
|
|
445
458
|
cwd: cwd || process.cwd(),
|
|
@@ -453,11 +466,29 @@ export function runGitAsync(args, cwd, opts = {}) {
|
|
|
453
466
|
let stdout = '';
|
|
454
467
|
let stderr = '';
|
|
455
468
|
let done = false;
|
|
456
|
-
const finish = (r) => {
|
|
469
|
+
const finish = (r) => {
|
|
470
|
+
if (done) return;
|
|
471
|
+
done = true;
|
|
472
|
+
clearTimeout(timer);
|
|
473
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
474
|
+
resolve(r);
|
|
475
|
+
};
|
|
457
476
|
const timer = setTimeout(() => {
|
|
458
|
-
try { child.kill(); } catch {}
|
|
459
|
-
|
|
477
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
478
|
+
// The partial output is returned rather than dropped: it is often still useful, and an empty
|
|
479
|
+
// string would look like "git had nothing to say" instead of "git was cut short".
|
|
480
|
+
finish({ ok: false, stdout, stderr, code: null, error: 'git timed out' });
|
|
460
481
|
}, timeout);
|
|
482
|
+
// `interrupted` is a distinct error from a timeout, so the caller can tell a user's Esc from a
|
|
483
|
+
// command that hung.
|
|
484
|
+
const onAbort = () => {
|
|
485
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
486
|
+
finish({ ok: false, stdout, stderr, code: null, error: 'interrupted' });
|
|
487
|
+
};
|
|
488
|
+
if (signal) {
|
|
489
|
+
if (signal.aborted) { clearTimeout(timer); onAbort(); return; }
|
|
490
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
491
|
+
}
|
|
461
492
|
if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
|
|
462
493
|
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
|
|
463
494
|
child.on('error', (e) => finish({ ok: false, stdout: '', stderr: '', code: null, error: e.message }));
|
package/src/llm.js
CHANGED
|
@@ -421,6 +421,12 @@ export class LLM {
|
|
|
421
421
|
|
|
422
422
|
for (let attempt = 1; attempt <= RETRIES; attempt++) {
|
|
423
423
|
attempts = attempt;
|
|
424
|
+
// Reset per attempt. Left holding the PREVIOUS attempt's response, a network error on
|
|
425
|
+
// this attempt left `res` truthy and `!res.ok` true, so the loop reported the old
|
|
426
|
+
// status and the old body — `LLM request failed (503)` for a connection that was
|
|
427
|
+
// actually refused, with `lastError` (the real cause) never reaching the user.
|
|
428
|
+
res = null;
|
|
429
|
+
lastError = null;
|
|
424
430
|
// Fresh controller per attempt so an abort aborts this specific request
|
|
425
431
|
// and the retry loop stops.
|
|
426
432
|
this.controller = new AbortController();
|