@nanhara/hara 0.122.2 → 0.122.4
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/CHANGELOG.md +108 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +31 -17
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/sessions.js +3 -3
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/org/projects.js +3 -3
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/sync-sleep.js +46 -0
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/task.js +3 -3
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/index.js
CHANGED
|
@@ -9,7 +9,6 @@ import { setTheme } from "./tui/theme.js";
|
|
|
9
9
|
import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
|
|
10
10
|
import { nextMode as cycleMode } from "./tui/InputBox.js";
|
|
11
11
|
import { stdin, stdout } from "node:process";
|
|
12
|
-
import { execFileSync } from "node:child_process";
|
|
13
12
|
import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
|
14
13
|
import { homedir, tmpdir } from "node:os";
|
|
15
14
|
import { fileURLToPath } from "node:url";
|
|
@@ -17,6 +16,7 @@ import { randomUUID } from "node:crypto";
|
|
|
17
16
|
import { dirname, join, relative, resolve } from "node:path";
|
|
18
17
|
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, providerDefaultBaseURL, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, REASONING_EFFORTS, } from "./config.js";
|
|
19
18
|
import { runAgent } from "./agent/loop.js";
|
|
19
|
+
import { formatAgentDuration, parseAgentRunTimeoutMs, MIN_AGENT_RUN_TIMEOUT_MS, MAX_AGENT_RUN_TIMEOUT_MS, MAX_AGENT_MAX_ROUNDS, } from "./agent/limits.js";
|
|
20
20
|
import { parseSchemaArg, structuredOutputTool, STRUCTURED_INSTRUCTION, STRUCTURED_NUDGE } from "./agent/structured.js";
|
|
21
21
|
import { notifyDone } from "./notify.js";
|
|
22
22
|
import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
|
|
@@ -37,8 +37,8 @@ import { mapLimit, maxParallel } from "./concurrency.js";
|
|
|
37
37
|
import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence, commitMessageInput, protectedStagedPaths, protectedTrackedWorkingTreePaths, protectedWorkingTreePaths, } from "./org/review-chain.js";
|
|
38
38
|
import { parseSchedule, describeSchedule, nextRun, validTz } from "./cron/schedule.js";
|
|
39
39
|
import { parseDeliver } from "./cron/deliver.js";
|
|
40
|
-
import { addJob, removeJob, setEnabled, resolveJob, loadJobs,
|
|
41
|
-
import { runTick,
|
|
40
|
+
import { addJob, removeJob, setEnabled, resolveJob, loadJobs, logPath } from "./cron/store.js";
|
|
41
|
+
import { runTick, runJobTracked, runSelfAttached, selfArgv } from "./cron/runner.js";
|
|
42
42
|
import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
|
|
43
43
|
import { getTools } from "./tools/registry.js";
|
|
44
44
|
import { resetReachability } from "./tools/net-reachability.js";
|
|
@@ -47,6 +47,7 @@ import { EXPLORE_SYSTEM } from "./tools/agent.js";
|
|
|
47
47
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
48
48
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
49
49
|
import { resolvePlatform } from "./providers/registry.js";
|
|
50
|
+
import { boundedProviderTurn } from "./providers/bounded-turn.js";
|
|
50
51
|
import { levelsFor } from "./tui/model-picker.js";
|
|
51
52
|
import { listModels } from "./providers/models.js";
|
|
52
53
|
import { listJobs, tailJob, killJob } from "./exec/jobs.js";
|
|
@@ -67,11 +68,12 @@ function renderBgJobs() {
|
|
|
67
68
|
return `Background jobs — /jobs tail <id> · /jobs kill <id>:\n${rows.join("\n")}`;
|
|
68
69
|
}
|
|
69
70
|
import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
|
|
70
|
-
import {
|
|
71
|
+
import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
|
|
72
|
+
import { homeWorkspaceActionError, isHomeWorkspace } from "./context/workspace-scope.js";
|
|
71
73
|
import { getEmbedder } from "./search/embed.js";
|
|
72
|
-
import {
|
|
74
|
+
import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, indexExists } from "./search/semindex.js";
|
|
73
75
|
import { searchHybrid } from "./search/hybrid.js";
|
|
74
|
-
import {
|
|
76
|
+
import { expandMentionsAsync, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
|
|
75
77
|
import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
|
|
76
78
|
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
77
79
|
import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
@@ -299,24 +301,18 @@ async function pingProvider(args) {
|
|
|
299
301
|
const prov = provider === "anthropic"
|
|
300
302
|
? createAnthropicProvider({ apiKey, model, baseURL })
|
|
301
303
|
: createOpenAIProvider({ apiKey, model, baseURL, label: provider });
|
|
302
|
-
const ctrl = new AbortController();
|
|
303
|
-
const timer = setTimeout(() => ctrl.abort(), 12_000);
|
|
304
304
|
try {
|
|
305
|
-
const r = await prov
|
|
305
|
+
const r = await boundedProviderTurn(prov, {
|
|
306
306
|
system: "Reply with the single word: ok",
|
|
307
307
|
history: [{ role: "user", content: "ping" }],
|
|
308
308
|
tools: [],
|
|
309
309
|
onText: () => { },
|
|
310
|
-
|
|
311
|
-
});
|
|
310
|
+
}, { timeoutMs: 12_000, label: "provider connectivity check" });
|
|
312
311
|
return r.stop !== "error";
|
|
313
312
|
}
|
|
314
313
|
catch {
|
|
315
314
|
return false;
|
|
316
315
|
}
|
|
317
|
-
finally {
|
|
318
|
-
clearTimeout(timer);
|
|
319
|
-
}
|
|
320
316
|
}
|
|
321
317
|
/** Interactive first-run setup: pick a provider (numbered menu), API key (masked), and model →
|
|
322
318
|
* ~/.hara/config.json. GLM/DeepSeek/OpenRouter and "custom" route through the OpenAI-compatible
|
|
@@ -390,23 +386,32 @@ async function runSetup() {
|
|
|
390
386
|
rl.close();
|
|
391
387
|
}
|
|
392
388
|
}
|
|
393
|
-
|
|
389
|
+
function agentRunLimits(cfg) {
|
|
390
|
+
return { timeoutMs: cfg.runTimeoutMs, maxRounds: cfg.maxAgentRounds };
|
|
391
|
+
}
|
|
392
|
+
async function runInit(provider, cwd, sandbox = "off", cfg) {
|
|
393
|
+
if (isHomeWorkspace(cwd))
|
|
394
|
+
throw new Error(homeWorkspaceActionError("initialize AGENTS.md"));
|
|
394
395
|
const history = [{ role: "user", content: INIT_PROMPT }];
|
|
395
|
-
await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true });
|
|
396
|
+
await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true, ...(cfg ? agentRunLimits(cfg) : {}) });
|
|
396
397
|
}
|
|
397
398
|
/** Stage everything and commit with an AI-written message. Returns a one-line summary or "error: …".
|
|
398
399
|
* Used by `hara org --commit`; the caller guards on a clean start tree so this only captures the run's work. */
|
|
399
|
-
async function autoCommit(provider, cwd) {
|
|
400
|
+
async function autoCommit(provider, cwd, signal) {
|
|
401
|
+
if (signal?.aborted)
|
|
402
|
+
return "error: commit interrupted before staging";
|
|
400
403
|
const before = protectedWorkingTreePaths(cwd);
|
|
401
404
|
if (before.length)
|
|
402
405
|
return `error: refusing to stage protected path(s): ${before.map((p) => JSON.stringify(p)).join(", ")}`;
|
|
403
406
|
try {
|
|
404
|
-
await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
407
|
+
await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000, signal });
|
|
405
408
|
}
|
|
406
409
|
catch {
|
|
407
410
|
/* fall through — empty diff is reported below */
|
|
408
411
|
}
|
|
409
412
|
const protectedAfterStage = protectedStagedPaths(cwd);
|
|
413
|
+
if (signal?.aborted)
|
|
414
|
+
return "error: commit interrupted after staging; nothing was committed";
|
|
410
415
|
if (protectedAfterStage.length) {
|
|
411
416
|
return `error: refusing to inspect or commit protected staged path(s): ${protectedAfterStage.map((p) => JSON.stringify(p)).join(", ")}`;
|
|
412
417
|
}
|
|
@@ -419,12 +424,16 @@ async function autoCommit(provider, cwd) {
|
|
|
419
424
|
const changeInput = commitMessageInput(staged);
|
|
420
425
|
if (!changeInput.trim())
|
|
421
426
|
return "nothing to commit";
|
|
422
|
-
const r = await provider
|
|
427
|
+
const r = await boundedProviderTurn(provider, {
|
|
423
428
|
system: COMMIT_SYSTEM,
|
|
424
429
|
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n${changeInput.slice(0, 120_000)}` }],
|
|
425
430
|
tools: [],
|
|
426
431
|
onText: () => { },
|
|
427
|
-
});
|
|
432
|
+
}, { timeoutMs: 30_000, label: "commit message generation", signal });
|
|
433
|
+
if (signal?.aborted)
|
|
434
|
+
return "error: commit interrupted; nothing was committed";
|
|
435
|
+
if (r.stop === "error")
|
|
436
|
+
return `error: commit message generation failed (${r.errorMsg ?? "provider error"})`;
|
|
428
437
|
const msg = stripCommitFence(r.text);
|
|
429
438
|
if (!msg)
|
|
430
439
|
return "error: no commit message produced";
|
|
@@ -432,10 +441,12 @@ async function autoCommit(provider, cwd) {
|
|
|
432
441
|
writeFileSync(tmp, msg + "\n", "utf8");
|
|
433
442
|
try {
|
|
434
443
|
const protectedBeforeCommit = protectedStagedPaths(cwd);
|
|
444
|
+
if (signal?.aborted)
|
|
445
|
+
return "error: commit interrupted; nothing was committed";
|
|
435
446
|
if (protectedBeforeCommit.length) {
|
|
436
447
|
return `error: staged paths changed while writing the message; protected path(s) will not be committed: ${protectedBeforeCommit.map((p) => JSON.stringify(p)).join(", ")}`;
|
|
437
448
|
}
|
|
438
|
-
const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
449
|
+
const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000, signal });
|
|
439
450
|
return (res.stdout || "").trim().split("\n")[0] || "committed";
|
|
440
451
|
}
|
|
441
452
|
catch (e) {
|
|
@@ -451,8 +462,10 @@ async function autoCommit(provider, cwd) {
|
|
|
451
462
|
}
|
|
452
463
|
}
|
|
453
464
|
/** Format an autoCommit result + emit it. */
|
|
454
|
-
async function commitStep(provider, cwd) {
|
|
455
|
-
|
|
465
|
+
async function commitStep(provider, cwd, signal) {
|
|
466
|
+
if (signal?.aborted)
|
|
467
|
+
return;
|
|
468
|
+
const r = await autoCommit(provider, cwd, signal);
|
|
456
469
|
if (r.startsWith("error:"))
|
|
457
470
|
out(c.red(`✗ ${r}\n`));
|
|
458
471
|
else if (r === "nothing to commit")
|
|
@@ -492,12 +505,14 @@ async function runOrg(task, o) {
|
|
|
492
505
|
role = kw.role;
|
|
493
506
|
}
|
|
494
507
|
else {
|
|
495
|
-
const r = await o.baseProvider
|
|
508
|
+
const r = await boundedProviderTurn(o.baseProvider, {
|
|
496
509
|
system: "You are a task dispatcher. Reply with only a role id.",
|
|
497
510
|
history: [{ role: "user", content: buildDispatchPrompt(task, roles) }],
|
|
498
511
|
tools: [],
|
|
499
512
|
onText: () => { },
|
|
500
|
-
});
|
|
513
|
+
}, { timeoutMs: 20_000, label: "role dispatch" });
|
|
514
|
+
if (r.stop === "error")
|
|
515
|
+
out(c.yellow(`(role dispatch unavailable — using ${roles[0].id})\n`));
|
|
501
516
|
role = parseRoleId(r.text, roles) ?? roles[0];
|
|
502
517
|
}
|
|
503
518
|
}
|
|
@@ -508,7 +523,7 @@ async function runOrg(task, o) {
|
|
|
508
523
|
? ((await buildProvider({ ...o.cfg, model: __roleModel })) ?? o.baseProvider)
|
|
509
524
|
: o.baseProvider;
|
|
510
525
|
const toolFilter = roleToolFilter(role);
|
|
511
|
-
const history = [{ role: "user", content:
|
|
526
|
+
const history = [{ role: "user", content: await expandMentionsAsync(task, o.cwd) }];
|
|
512
527
|
const runImplementer = async () => {
|
|
513
528
|
return runAgent(history, {
|
|
514
529
|
provider: roleProvider,
|
|
@@ -520,6 +535,7 @@ async function runOrg(task, o) {
|
|
|
520
535
|
stats: o.stats,
|
|
521
536
|
systemOverride: role.system,
|
|
522
537
|
toolFilter,
|
|
538
|
+
...agentRunLimits(o.cfg),
|
|
523
539
|
...(role.readOnly ? { hooks: false } : {}),
|
|
524
540
|
});
|
|
525
541
|
};
|
|
@@ -571,6 +587,7 @@ async function runOrg(task, o) {
|
|
|
571
587
|
systemOverride: revSystem,
|
|
572
588
|
toolFilter: revTools,
|
|
573
589
|
hooks: false,
|
|
590
|
+
...agentRunLimits(o.cfg),
|
|
574
591
|
});
|
|
575
592
|
if (reviewerOutcome.status !== "completed")
|
|
576
593
|
return reviewerOutcome;
|
|
@@ -623,6 +640,7 @@ async function executeAtom(atom, plan, done, roles, o) {
|
|
|
623
640
|
toolFilter,
|
|
624
641
|
...(role?.readOnly ? { hooks: false } : {}),
|
|
625
642
|
quiet: o.parallel, // concurrent atoms would otherwise interleave their streamed output
|
|
643
|
+
...agentRunLimits(o.cfg),
|
|
626
644
|
});
|
|
627
645
|
const failure = runFailureDetail(outcome);
|
|
628
646
|
if (failure) {
|
|
@@ -798,7 +816,7 @@ const SESSION_NAME_SYSTEM = "Name this coding session as a SHORT slug: 2–4 Eng
|
|
|
798
816
|
"gist to English (use pinyin only if a term is untranslatable). Output ONLY the slug.";
|
|
799
817
|
/** One short model call → a 2–4 word English kebab-case session name summarizing the work.
|
|
800
818
|
* Always ASCII (translates non-English gist). Falls back to the lexical title on any failure. */
|
|
801
|
-
async function nameSession(provider, history) {
|
|
819
|
+
async function nameSession(provider, history, signal) {
|
|
802
820
|
const text = (m) => {
|
|
803
821
|
if (!m)
|
|
804
822
|
return "";
|
|
@@ -810,12 +828,17 @@ async function nameSession(provider, history) {
|
|
|
810
828
|
};
|
|
811
829
|
const basis = `User: ${text(history.find((m) => m.role === "user")).slice(0, 800)}\n` +
|
|
812
830
|
`Assistant: ${text(history.find((m) => m.role === "assistant")).slice(0, 800)}`;
|
|
831
|
+
const fallback = titleFrom(history);
|
|
832
|
+
if (signal?.aborted)
|
|
833
|
+
return fallback;
|
|
813
834
|
try {
|
|
814
|
-
const r = await provider
|
|
815
|
-
|
|
835
|
+
const r = await boundedProviderTurn(provider, { system: SESSION_NAME_SYSTEM, history: [{ role: "user", content: basis }], tools: [], onText: () => { } }, { timeoutMs: 10_000, label: "session naming", signal });
|
|
836
|
+
if (signal?.aborted || r.stop === "error")
|
|
837
|
+
return fallback;
|
|
838
|
+
return slugify(r.text) || fallback;
|
|
816
839
|
}
|
|
817
840
|
catch {
|
|
818
|
-
return
|
|
841
|
+
return fallback;
|
|
819
842
|
}
|
|
820
843
|
}
|
|
821
844
|
/** Render a proposed plan as a bordered block for the transcript (codex ProposedPlanCell-style).
|
|
@@ -847,26 +870,26 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
|
|
|
847
870
|
// pin their structure and serve's session.compact shares the exact same distillation.
|
|
848
871
|
/** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
|
|
849
872
|
* /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
|
|
850
|
-
async function compactConversation(provider, history, meta, stats) {
|
|
851
|
-
if (history.length < 2)
|
|
873
|
+
async function compactConversation(provider, history, meta, stats, signal) {
|
|
874
|
+
if (history.length < 2 || signal?.aborted)
|
|
852
875
|
return null;
|
|
853
|
-
const r = await provider
|
|
876
|
+
const r = await boundedProviderTurn(provider, {
|
|
854
877
|
system: COMPACT_SYSTEM,
|
|
855
878
|
history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
856
879
|
tools: [],
|
|
857
880
|
onText: () => { },
|
|
858
|
-
});
|
|
859
|
-
if (r.stop === "error")
|
|
881
|
+
}, { timeoutMs: 60_000, label: "conversation compaction", signal });
|
|
882
|
+
if (signal?.aborted || r.stop === "error")
|
|
860
883
|
return null;
|
|
861
884
|
const summary = r.text.trim();
|
|
862
885
|
if (!summary)
|
|
863
886
|
return null;
|
|
864
|
-
|
|
865
|
-
history.length = 0;
|
|
866
|
-
history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
887
|
+
const workingSet = workingSetFromSummary(summary);
|
|
867
888
|
// TW5-style file restore: the summary alone loses the working set's ACTUAL content — re-attach the
|
|
868
889
|
// most recently touched files (current on-disk state, byte-capped) so work continues without re-reads.
|
|
869
890
|
const restore = buildFileRestore(recentTouched(5), (p) => {
|
|
891
|
+
if (signal?.aborted)
|
|
892
|
+
return null;
|
|
870
893
|
try {
|
|
871
894
|
return readModelContextFileSync(p, 32 * 1024);
|
|
872
895
|
}
|
|
@@ -874,6 +897,12 @@ async function compactConversation(provider, history, meta, stats) {
|
|
|
874
897
|
return null;
|
|
875
898
|
}
|
|
876
899
|
});
|
|
900
|
+
// Cancellation during the file snapshot must leave the original conversation untouched.
|
|
901
|
+
if (signal?.aborted)
|
|
902
|
+
return null;
|
|
903
|
+
meta.workingSet = workingSet; // survives the history wipe + injects into the next turns
|
|
904
|
+
history.length = 0;
|
|
905
|
+
history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
877
906
|
if (restore)
|
|
878
907
|
history.push({ role: "user", content: restore });
|
|
879
908
|
stats.input += r.usage?.input ?? 0;
|
|
@@ -885,7 +914,9 @@ async function compactConversation(provider, history, meta, stats) {
|
|
|
885
914
|
/** Auto-compact (à la Claude Code) when the last turn filled the context past the threshold, so the NEXT turn
|
|
886
915
|
* doesn't overflow. Opt-out via `autoCompact: false` / `HARA_AUTO_COMPACT=0`. Best-effort; `notify` surfaces
|
|
887
916
|
* a one-line status. Returns true if it compacted. */
|
|
888
|
-
async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
|
|
917
|
+
async function maybeAutoCompact(provider, history, meta, stats, cfg, notify, signal) {
|
|
918
|
+
if (signal?.aborted)
|
|
919
|
+
return false;
|
|
889
920
|
const lastInput = stats.lastInput ?? 0;
|
|
890
921
|
const pct = bar.ctxPctFor(cfg.model, lastInput);
|
|
891
922
|
// Two triggers, whichever hits first: % of window (small-window models) OR an absolute token cap
|
|
@@ -895,13 +926,17 @@ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
|
|
|
895
926
|
const overCap = shouldAutoCompactTokens(lastInput, history.length, cfg.autoCompact, cap);
|
|
896
927
|
if (!overPct && !overCap)
|
|
897
928
|
return false;
|
|
929
|
+
if (signal?.aborted)
|
|
930
|
+
return false;
|
|
898
931
|
notify(`✻ Auto-compacting conversation (context ${pct}% full, ~${Math.round(lastInput / 1000)}k tok)…`);
|
|
899
|
-
const summary = await compactConversation(provider, history, meta, stats);
|
|
932
|
+
const summary = await compactConversation(provider, history, meta, stats, signal);
|
|
933
|
+
if (signal?.aborted)
|
|
934
|
+
return false;
|
|
900
935
|
notify(summary ? `(auto-compacted — context replaced with a summary; ${meta.workingSet?.length ?? 0} notes kept)` : "(auto-compact failed — use /compact or /clear)");
|
|
901
936
|
return !!summary;
|
|
902
937
|
}
|
|
903
938
|
/** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
|
|
904
|
-
async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
|
|
939
|
+
async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId, signal) {
|
|
905
940
|
const roles = loadRoles(cwd);
|
|
906
941
|
const roleRef = roleId?.trim();
|
|
907
942
|
if (roleId !== undefined && !roleRef)
|
|
@@ -940,8 +975,9 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
|
|
|
940
975
|
const toolFilter = subagentToolFilter(role, (n) => READONLY_TOOLS.has(n));
|
|
941
976
|
const subHistory = [{ role: "user", content: task }];
|
|
942
977
|
const todoScope = `subagent:${randomUUID()}`;
|
|
978
|
+
let outcome;
|
|
943
979
|
try {
|
|
944
|
-
await runAgent(subHistory, {
|
|
980
|
+
outcome = await runAgent(subHistory, {
|
|
945
981
|
provider,
|
|
946
982
|
ctx: { cwd, sandbox, todoScope }, // isolated checklist; no spawn → no recursion
|
|
947
983
|
approval: "full-auto", // read-only tools, so no prompts (can't prompt in parallel)
|
|
@@ -953,6 +989,9 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
|
|
|
953
989
|
toolFilter,
|
|
954
990
|
hooks: false,
|
|
955
991
|
quiet: true,
|
|
992
|
+
signal,
|
|
993
|
+
timeoutMs: Math.min(cfg.runTimeoutMs, 8 * 60_000),
|
|
994
|
+
maxRounds: Math.min(cfg.maxAgentRounds, 24),
|
|
956
995
|
});
|
|
957
996
|
}
|
|
958
997
|
finally {
|
|
@@ -961,6 +1000,15 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
|
|
|
961
1000
|
resetRepeatGuard(todoScope);
|
|
962
1001
|
clearTouched(todoScope);
|
|
963
1002
|
}
|
|
1003
|
+
if (outcome.status !== "completed") {
|
|
1004
|
+
const reason = outcome.error?.trim()
|
|
1005
|
+
|| (outcome.status === "empty"
|
|
1006
|
+
? "the model returned an empty response"
|
|
1007
|
+
: outcome.stopReason
|
|
1008
|
+
? `the run stopped (${outcome.stopReason})`
|
|
1009
|
+
: `the run ended with status ${outcome.status}`);
|
|
1010
|
+
return `Error: sub-agent ${reason}`;
|
|
1011
|
+
}
|
|
964
1012
|
for (let i = subHistory.length - 1; i >= 0; i--) {
|
|
965
1013
|
const m = subHistory[i];
|
|
966
1014
|
if (m.role === "assistant" && typeof m.text === "string" && m.text.trim())
|
|
@@ -996,8 +1044,15 @@ function runDoctor(cfg) {
|
|
|
996
1044
|
`${dot} plugins ${(() => { const inst = listInstalled(); const on = enabledPlugins().length; return inst.length ? c.dim(`${on}/${inst.length} enabled: ${inst.map((p) => p.name).slice(0, 6).join(", ")}`) : c.dim("none — hara plugin add <source>"); })()}`,
|
|
997
1045
|
`${dot} mcp ${c.dim(`client: ${Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length} server(s) · serve: ${mcpServeToolNames().length} read tools via \`hara mcp\``)}`,
|
|
998
1046
|
`${dot} hooks ${(() => { const ph = pluginHooks(); const pre = (cfg.hooks.PreToolUse ?? []).length + (ph.PreToolUse ?? []).length; const post = (cfg.hooks.PostToolUse ?? []).length + (ph.PostToolUse ?? []).length; return pre + post ? c.dim(`${pre} pre · ${post} post`) : c.dim("none — config.json \"hooks\""); })()}`,
|
|
1047
|
+
`${dot} run-limits ${c.bold(formatAgentDuration(cfg.runTimeoutMs))}${c.dim(" total · ")}${c.bold(String(cfg.maxAgentRounds))}${c.dim(" rounds · sub-agents ≤8m/24")}`,
|
|
999
1048
|
`${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
|
|
1000
|
-
`${dot} cron ${(() => {
|
|
1049
|
+
`${dot} cron ${(() => { try {
|
|
1050
|
+
const n = loadJobs().length;
|
|
1051
|
+
return n ? `${n} job(s) · ${isInstalled() ? c.green("scheduler installed") : c.yellow("scheduler off — hara cron install")}` : c.dim("no jobs — hara cron add");
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
return c.red("job store invalid — run hara cron list");
|
|
1055
|
+
} })()}`,
|
|
1001
1056
|
`${dot} input ${cfg.vimMode ? c.bold("vim") + c.dim(" (modal)") : c.dim("default — hara config set vimMode true for vim keys")}`,
|
|
1002
1057
|
];
|
|
1003
1058
|
return lines.join("\n");
|
|
@@ -1049,13 +1104,18 @@ program
|
|
|
1049
1104
|
.description("analyze the project and (re)generate AGENTS.md")
|
|
1050
1105
|
.action(async () => {
|
|
1051
1106
|
const cfg = loadConfig();
|
|
1107
|
+
if (isHomeWorkspace(cfg.cwd)) {
|
|
1108
|
+
out(c.red(homeWorkspaceActionError("initialize AGENTS.md")) + "\n");
|
|
1109
|
+
process.exitCode = 2;
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1052
1112
|
const provider = await buildProvider(cfg);
|
|
1053
1113
|
if (!provider) {
|
|
1054
1114
|
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
1055
1115
|
process.exit(1);
|
|
1056
1116
|
}
|
|
1057
1117
|
out(c.dim("Analyzing project to generate AGENTS.md…\n"));
|
|
1058
|
-
await runInit(provider, cfg.cwd, cfg.sandbox);
|
|
1118
|
+
await runInit(provider, cfg.cwd, cfg.sandbox, cfg);
|
|
1059
1119
|
});
|
|
1060
1120
|
program
|
|
1061
1121
|
.command("sessions")
|
|
@@ -1074,7 +1134,7 @@ program
|
|
|
1074
1134
|
program
|
|
1075
1135
|
.command("resume [id]")
|
|
1076
1136
|
.description("resume a session — no id resumes the most recent here (list ids with `hara sessions`)")
|
|
1077
|
-
.action((id) => {
|
|
1137
|
+
.action(async (id) => {
|
|
1078
1138
|
let full;
|
|
1079
1139
|
if (id) {
|
|
1080
1140
|
full = resolveSessionId(id) ?? undefined;
|
|
@@ -1092,8 +1152,22 @@ program
|
|
|
1092
1152
|
full = latest.meta.id;
|
|
1093
1153
|
}
|
|
1094
1154
|
out(c.dim(`↩ resuming ${shortId(full)}…\n`));
|
|
1095
|
-
//
|
|
1096
|
-
|
|
1155
|
+
// Reuse the existing --resume path exactly (one engine), inheriting this terminal. selfArgv() inside
|
|
1156
|
+
// runSelfAttached distinguishes node+entry from a Bun-compiled binary (whose argv[1] is a user arg).
|
|
1157
|
+
try {
|
|
1158
|
+
const result = await runSelfAttached(["--resume", full]);
|
|
1159
|
+
if (result.signal) {
|
|
1160
|
+
out(c.yellow(`Resumed Hara process stopped by ${result.signal}.\n`));
|
|
1161
|
+
process.exitCode = 1;
|
|
1162
|
+
}
|
|
1163
|
+
else if (result.code) {
|
|
1164
|
+
process.exitCode = result.code;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
catch (error) {
|
|
1168
|
+
out(c.red(`Could not start the resumed Hara process: ${error instanceof Error ? error.message : String(error)}\n`));
|
|
1169
|
+
process.exitCode = 1;
|
|
1170
|
+
}
|
|
1097
1171
|
});
|
|
1098
1172
|
program
|
|
1099
1173
|
.command("org <task...>")
|
|
@@ -1149,7 +1223,7 @@ program
|
|
|
1149
1223
|
sandbox: cfg.sandbox,
|
|
1150
1224
|
approval: "full-auto",
|
|
1151
1225
|
confirm: async () => true,
|
|
1152
|
-
projectContext:
|
|
1226
|
+
projectContext: loadAgentContext(orgCwd) || undefined,
|
|
1153
1227
|
stats,
|
|
1154
1228
|
forceRole,
|
|
1155
1229
|
review: opts2.review,
|
|
@@ -1213,7 +1287,7 @@ program
|
|
|
1213
1287
|
sandbox: cfg.sandbox,
|
|
1214
1288
|
approval: "full-auto",
|
|
1215
1289
|
confirm: async () => true,
|
|
1216
|
-
projectContext:
|
|
1290
|
+
projectContext: loadAgentContext(cfg.cwd) || undefined,
|
|
1217
1291
|
stats,
|
|
1218
1292
|
parallel: opts.parallel,
|
|
1219
1293
|
};
|
|
@@ -1262,6 +1336,18 @@ program
|
|
|
1262
1336
|
.option("--all", "index everything")
|
|
1263
1337
|
.action(async (opts) => {
|
|
1264
1338
|
const cfg = loadConfig();
|
|
1339
|
+
const cwd = process.cwd();
|
|
1340
|
+
let doRepo = !!(opts.all || opts.repo || (!opts.assets && !opts.all));
|
|
1341
|
+
const doAssets = !!(opts.all || opts.assets);
|
|
1342
|
+
if (doRepo && isHomeWorkspace(cwd)) {
|
|
1343
|
+
if (!doAssets) {
|
|
1344
|
+
out(c.red(homeWorkspaceActionError("build a repository index")) + "\n");
|
|
1345
|
+
process.exitCode = 2;
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
out(c.yellow(homeWorkspaceActionError("build the repository part of --all")) + c.dim(" Global assets/memory will still be indexed.\n"));
|
|
1349
|
+
doRepo = false;
|
|
1350
|
+
}
|
|
1265
1351
|
const embed = getEmbedder(cfg);
|
|
1266
1352
|
if (!embed) {
|
|
1267
1353
|
out(c.yellow("Semantic search is off — search stays lexical (which still works).\n"));
|
|
@@ -1270,10 +1356,7 @@ program
|
|
|
1270
1356
|
out(c.dim(" hara config set embedProvider qwen # DashScope text-embedding-v3 (uses your key)\n"));
|
|
1271
1357
|
return;
|
|
1272
1358
|
}
|
|
1273
|
-
const cwd = process.cwd();
|
|
1274
1359
|
const model = `${cfg.embedProvider}:${cfg.embedModel ?? "default"}`;
|
|
1275
|
-
const doRepo = opts.all || opts.repo || (!opts.assets && !opts.all);
|
|
1276
|
-
const doAssets = opts.all || opts.assets;
|
|
1277
1360
|
const build = async (name, chunks, blurb) => {
|
|
1278
1361
|
if (!chunks.length)
|
|
1279
1362
|
return void out(c.dim(`Nothing to index for ${name}.\n`));
|
|
@@ -1288,11 +1371,38 @@ program
|
|
|
1288
1371
|
out(c.dim("Check the embedding endpoint/key; search still works lexically.\n"));
|
|
1289
1372
|
}
|
|
1290
1373
|
};
|
|
1291
|
-
|
|
1292
|
-
|
|
1374
|
+
const collectionOptions = {
|
|
1375
|
+
maxFiles: 50_000,
|
|
1376
|
+
maxDirectories: 100_000,
|
|
1377
|
+
maxEntries: 500_000,
|
|
1378
|
+
timeoutMs: 60_000,
|
|
1379
|
+
yieldEvery: 128,
|
|
1380
|
+
};
|
|
1381
|
+
const noteTruncation = (name, collection) => {
|
|
1382
|
+
if (collection.truncated) {
|
|
1383
|
+
out(c.yellow(`Index collection for ${name} stopped at its ${collection.reason?.replace("_", " ") ?? "safety limit"}; the partial index is not being published.\n`));
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
if (doRepo) {
|
|
1387
|
+
const repo = await collectRepoChunksAsync(findProjectRoot(cwd), collectionOptions);
|
|
1388
|
+
noteTruncation("repo", repo);
|
|
1389
|
+
if (!repo.truncated)
|
|
1390
|
+
await build("repo", repo.chunks, "codebase_search");
|
|
1391
|
+
}
|
|
1293
1392
|
if (doAssets) {
|
|
1294
|
-
|
|
1295
|
-
|
|
1393
|
+
const [assets, skills, memory] = await Promise.all([
|
|
1394
|
+
collectDirChunksAsync(assetsDir(), "code-assets", collectionOptions),
|
|
1395
|
+
collectDirChunksAsync(globalSkillsDir(), "skills", collectionOptions),
|
|
1396
|
+
collectDirChunksAsync(memoryDir("global", cwd), "memory", collectionOptions),
|
|
1397
|
+
]);
|
|
1398
|
+
noteTruncation("assets", assets);
|
|
1399
|
+
noteTruncation("skills", skills);
|
|
1400
|
+
noteTruncation("memory", memory);
|
|
1401
|
+
if (!assets.truncated && !skills.truncated) {
|
|
1402
|
+
await build("assets", [...assets.chunks, ...skills.chunks], "recall");
|
|
1403
|
+
}
|
|
1404
|
+
if (!memory.truncated)
|
|
1405
|
+
await build("memory", memory.chunks, "memory_search");
|
|
1296
1406
|
}
|
|
1297
1407
|
});
|
|
1298
1408
|
program
|
|
@@ -1734,12 +1844,36 @@ program
|
|
|
1734
1844
|
});
|
|
1735
1845
|
program
|
|
1736
1846
|
.command("gateway")
|
|
1737
|
-
.description("run a chat gateway
|
|
1847
|
+
.description("run a supported chat gateway so you can drive your local hara from your phone — opt-in daemon")
|
|
1738
1848
|
.option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk | wecom | signal", "telegram")
|
|
1739
1849
|
.option("--login", "(weixin) scan a QR to log in and save credentials, then exit")
|
|
1850
|
+
.option("--recover-outcome <message-id>", "recover exactly one interrupted/terminal run marker by its original platform message id")
|
|
1851
|
+
.option("--confirm-recovery <action:message-id>", "required exact confirmation: terminalize:<id> or delete-terminal:<id>")
|
|
1740
1852
|
.option("--cwd <dir>", "directory hara operates in per message (default: ~/.hara/workspace)")
|
|
1741
1853
|
.action(async (opts) => {
|
|
1742
1854
|
const mod = await import("./gateway/serve.js");
|
|
1855
|
+
if (opts.recoverOutcome !== undefined || opts.confirmRecovery !== undefined) {
|
|
1856
|
+
if (typeof opts.recoverOutcome !== "string" || typeof opts.confirmRecovery !== "string") {
|
|
1857
|
+
throw new Error("gateway outcome recovery requires both --recover-outcome <message-id> and --confirm-recovery <action:message-id>");
|
|
1858
|
+
}
|
|
1859
|
+
const result = await mod.recoverGatewayRunOutcome({
|
|
1860
|
+
platform: opts.platform,
|
|
1861
|
+
messageId: opts.recoverOutcome,
|
|
1862
|
+
confirmation: opts.confirmRecovery,
|
|
1863
|
+
});
|
|
1864
|
+
if (result === "missing")
|
|
1865
|
+
out(c.yellow("No outcome marker exists for that message id and gateway credential.\n"));
|
|
1866
|
+
else if (result === "terminalized") {
|
|
1867
|
+
out(c.green("✓ interrupted marker converted to terminal; one active slot is free and coding remains blocked from rerun.\n"));
|
|
1868
|
+
}
|
|
1869
|
+
else if (result === "already-terminal") {
|
|
1870
|
+
out(c.yellow("Marker is already terminal. Delete it only after platform redelivery is impossible, using delete-terminal:<same-message-id>.\n"));
|
|
1871
|
+
}
|
|
1872
|
+
else {
|
|
1873
|
+
out(c.yellow("✓ terminal marker removed. This message id is no longer protected if the platform redelivers it later.\n"));
|
|
1874
|
+
}
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1743
1877
|
if (opts.platform === "weixin" && opts.login) {
|
|
1744
1878
|
await mod.weixinLogin();
|
|
1745
1879
|
return;
|
|
@@ -1795,9 +1929,10 @@ program
|
|
|
1795
1929
|
effortLevels: levelsFor(resolvePlatform(live.provider, live.baseURL ?? providerDefaultBaseURL(live.provider)).reasoning).filter((e) => !!e),
|
|
1796
1930
|
};
|
|
1797
1931
|
},
|
|
1798
|
-
|
|
1932
|
+
runLimits: (targetCwd) => agentRunLimits(loadConfig({ cwd: targetCwd ?? cwd })),
|
|
1933
|
+
spawnSubagent: (provider, scwd, projectContext, stats, task, role, signal) => {
|
|
1799
1934
|
const live = loadConfig({ cwd: scwd });
|
|
1800
|
-
return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role);
|
|
1935
|
+
return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role, signal);
|
|
1801
1936
|
},
|
|
1802
1937
|
guardian: guardianOpt,
|
|
1803
1938
|
buildGuardian: async (targetCwd) => {
|
|
@@ -1953,9 +2088,10 @@ program
|
|
|
1953
2088
|
systemOverride: REVIEW_SYSTEM,
|
|
1954
2089
|
toolFilter: (n) => READONLY_TOOLS.has(n), // read-only: the reviewer can inspect, never edit
|
|
1955
2090
|
hooks: false,
|
|
1956
|
-
projectContext:
|
|
2091
|
+
projectContext: loadAgentContext(cfg.cwd) || undefined,
|
|
1957
2092
|
memory: memoryDigest(cfg.cwd),
|
|
1958
2093
|
stats,
|
|
2094
|
+
...agentRunLimits(cfg),
|
|
1959
2095
|
});
|
|
1960
2096
|
if (stats.input || stats.output)
|
|
1961
2097
|
out("\n" + statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
@@ -1998,12 +2134,12 @@ program
|
|
|
1998
2134
|
if (!changeInput.trim())
|
|
1999
2135
|
return void out(c.dim("Nothing staged. Stage changes with `git add`, or use `hara commit -a`.\n"));
|
|
2000
2136
|
out(c.dim("Writing a commit message…\n"));
|
|
2001
|
-
const r = await provider
|
|
2137
|
+
const r = await boundedProviderTurn(provider, {
|
|
2002
2138
|
system: COMMIT_SYSTEM,
|
|
2003
2139
|
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n${changeInput.slice(0, 120_000)}` }],
|
|
2004
2140
|
tools: [],
|
|
2005
2141
|
onText: () => { },
|
|
2006
|
-
});
|
|
2142
|
+
}, { timeoutMs: 30_000, label: "commit message generation" });
|
|
2007
2143
|
if (r.stop === "error")
|
|
2008
2144
|
return void out(c.red(`message generation failed: ${r.errorMsg ?? "provider error"}\n`));
|
|
2009
2145
|
const msg = r.text.trim().replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
|
@@ -2040,15 +2176,31 @@ program
|
|
|
2040
2176
|
}
|
|
2041
2177
|
});
|
|
2042
2178
|
function renderCronJobs() {
|
|
2043
|
-
const jobs = loadJobs();
|
|
2044
2179
|
const head = isInstalled() ? c.green("scheduler: installed") : c.yellow("scheduler: NOT installed — run `hara cron install`");
|
|
2180
|
+
let jobs;
|
|
2181
|
+
try {
|
|
2182
|
+
jobs = loadJobs();
|
|
2183
|
+
}
|
|
2184
|
+
catch (error) {
|
|
2185
|
+
return head + "\n" + c.red(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
2186
|
+
}
|
|
2045
2187
|
if (!jobs.length)
|
|
2046
2188
|
return head + "\n" + c.dim('No jobs. Add one: hara cron add "every 1h" "<task>"\n');
|
|
2047
2189
|
const now = Date.now();
|
|
2048
2190
|
const lines = jobs.map((j) => {
|
|
2049
2191
|
const nxt = nextRun(j, now);
|
|
2050
|
-
const
|
|
2051
|
-
|
|
2192
|
+
const nextLabel = nxt !== null && nxt <= now ? "due now" : nxt !== null ? new Date(nxt).toLocaleString() : "—";
|
|
2193
|
+
const elapsed = j.lastDurationMs === undefined ? "" : ` after ${formatAgentDuration(j.lastDurationMs)}`;
|
|
2194
|
+
const status = j.lastStatus === "running"
|
|
2195
|
+
? c.yellow(`running since ${new Date(j.runningSince ?? j.lastRunAt ?? now).toLocaleString()}`)
|
|
2196
|
+
: j.lastStatus === "timed_out"
|
|
2197
|
+
? c.red(`timed out${elapsed}`)
|
|
2198
|
+
: j.lastStatus === "ok"
|
|
2199
|
+
? c.green(`ok${elapsed}`)
|
|
2200
|
+
: j.lastStatus === "error"
|
|
2201
|
+
? c.red(`err${elapsed}`)
|
|
2202
|
+
: c.dim("—");
|
|
2203
|
+
return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode} · next ${nextLabel} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
|
|
2052
2204
|
});
|
|
2053
2205
|
return head + "\n" + lines.join("\n") + "\n";
|
|
2054
2206
|
}
|
|
@@ -2061,6 +2213,7 @@ cronCmd
|
|
|
2061
2213
|
.option("--command", "run the task as a plain SHELL COMMAND — deterministic, no agent, no tokens")
|
|
2062
2214
|
.option("--tz <zone>", 'IANA timezone for cron exprs (e.g. "Asia/Shanghai"); default = local time')
|
|
2063
2215
|
.option("--deliver <spec>", "push each run's result: telegram:<chatId> | feishu:<chatId> | webhook:<url>")
|
|
2216
|
+
.option("--alert-after <n>", "send a 🚨 after N consecutive failures (1..1000; default 3)")
|
|
2064
2217
|
.action((schedule, taskParts, opts) => {
|
|
2065
2218
|
const task = taskParts.join(" ");
|
|
2066
2219
|
if (opts.org && opts.command)
|
|
@@ -2077,30 +2230,73 @@ cronCmd
|
|
|
2077
2230
|
if ("error" in d)
|
|
2078
2231
|
return void out(c.red(d.error + "\n"));
|
|
2079
2232
|
}
|
|
2233
|
+
const alertAfter = opts.alertAfter === undefined ? undefined : Number(opts.alertAfter);
|
|
2234
|
+
if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
|
|
2235
|
+
return void out(c.red("--alert-after must be an integer from 1 to 1000\n"));
|
|
2236
|
+
}
|
|
2080
2237
|
const mode = opts.command ? "command" : opts.org ? "org" : "print";
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2238
|
+
let job;
|
|
2239
|
+
try {
|
|
2240
|
+
job = addJob({
|
|
2241
|
+
name: opts.name || task.slice(0, 48),
|
|
2242
|
+
schedule: sched,
|
|
2243
|
+
task,
|
|
2244
|
+
mode,
|
|
2245
|
+
cwd: process.cwd(),
|
|
2246
|
+
...(opts.tz ? { tz: opts.tz } : {}),
|
|
2247
|
+
...(opts.deliver ? { deliver: opts.deliver } : {}),
|
|
2248
|
+
...(alertAfter !== undefined ? { alertAfter } : {}),
|
|
2249
|
+
createdAt: Date.now(),
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
catch (error) {
|
|
2253
|
+
return void out(c.red(`${error instanceof Error ? error.message : String(error)}\n`));
|
|
2254
|
+
}
|
|
2255
|
+
out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)}${opts.tz ? ` @ ${opts.tz}` : ""} · ${job.mode}${opts.deliver ? ` · → ${opts.deliver}` : ""}${alertAfter !== undefined ? ` · alert ≥${alertAfter}` : ""} · cwd ${job.cwd}\n`));
|
|
2092
2256
|
if (!isInstalled())
|
|
2093
2257
|
out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
|
|
2094
2258
|
});
|
|
2095
2259
|
// Resolve an id/prefix to one job, printing a clear error for none / ambiguous (never act on a guess).
|
|
2096
2260
|
const cronResolve = (id) => {
|
|
2097
|
-
|
|
2261
|
+
let r;
|
|
2262
|
+
try {
|
|
2263
|
+
r = resolveJob(id);
|
|
2264
|
+
}
|
|
2265
|
+
catch (error) {
|
|
2266
|
+
out(c.red(`${error instanceof Error ? error.message : String(error)}\n`));
|
|
2267
|
+
return null;
|
|
2268
|
+
}
|
|
2098
2269
|
if (r === "ambiguous")
|
|
2099
2270
|
return void out(c.red(`ambiguous id "${id}" — matches multiple jobs; type more characters\n`)), null;
|
|
2100
2271
|
if (!r)
|
|
2101
2272
|
return void out(c.red(`no such job: ${id}\n`)), null;
|
|
2102
2273
|
return r;
|
|
2103
2274
|
};
|
|
2275
|
+
/** Commander actions otherwise inherit Node's default SIGTERM/SIGINT exit, which kills only the tick parent
|
|
2276
|
+
* and can orphan its detached job process group. Convert the signal into cooperative cancellation, await
|
|
2277
|
+
* tree cleanup + lock release, then retain the conventional shell exit status. */
|
|
2278
|
+
const withCronCliSignal = async (run) => {
|
|
2279
|
+
const controller = new AbortController();
|
|
2280
|
+
let interrupted;
|
|
2281
|
+
const stop = (signal) => {
|
|
2282
|
+
interrupted ??= signal;
|
|
2283
|
+
if (!controller.signal.aborted)
|
|
2284
|
+
controller.abort(new Error(`cron command interrupted by ${signal}`));
|
|
2285
|
+
};
|
|
2286
|
+
const onInt = () => stop("SIGINT");
|
|
2287
|
+
const onTerm = () => stop("SIGTERM");
|
|
2288
|
+
process.once("SIGINT", onInt);
|
|
2289
|
+
process.once("SIGTERM", onTerm);
|
|
2290
|
+
try {
|
|
2291
|
+
return await run(controller.signal);
|
|
2292
|
+
}
|
|
2293
|
+
finally {
|
|
2294
|
+
process.removeListener("SIGINT", onInt);
|
|
2295
|
+
process.removeListener("SIGTERM", onTerm);
|
|
2296
|
+
if (interrupted)
|
|
2297
|
+
process.exitCode = interrupted === "SIGINT" ? 130 : 143;
|
|
2298
|
+
}
|
|
2299
|
+
};
|
|
2104
2300
|
cronCmd.command("list").alias("ls").description("list scheduled jobs").action(() => out(renderCronJobs()));
|
|
2105
2301
|
cronCmd
|
|
2106
2302
|
.command("remove <id>")
|
|
@@ -2133,17 +2329,18 @@ cronCmd
|
|
|
2133
2329
|
if (!job)
|
|
2134
2330
|
return;
|
|
2135
2331
|
out(c.dim(`running ${job.id} (${job.name})…\n`));
|
|
2136
|
-
const r = await
|
|
2137
|
-
recordRun(job.id, Date.now(), r.ok ? "ok" : "error", r.error);
|
|
2332
|
+
const r = await withCronCliSignal((signal) => runJobTracked(job, { signal }));
|
|
2138
2333
|
out((r.ok ? c.green("✓ done") : c.red(`✗ ${r.error}`)) + c.dim(` · log: ${logPath(job.id)}\n`));
|
|
2139
2334
|
});
|
|
2140
2335
|
cronCmd
|
|
2141
2336
|
.command("tick")
|
|
2142
2337
|
.description("run all due jobs now (your OS scheduler calls this every minute)")
|
|
2143
2338
|
.action(async () => {
|
|
2144
|
-
const r = await runTick(Date.now());
|
|
2339
|
+
const r = await withCronCliSignal((signal) => runTick(Date.now(), undefined, { signal }));
|
|
2145
2340
|
if (r.skipped)
|
|
2146
2341
|
return void out(c.dim(`(skipped — ${r.skipped})\n`));
|
|
2342
|
+
if (r.stopped)
|
|
2343
|
+
return void out(c.yellow(`(stopped — ${r.stopped}; ran ${r.ran.length} job(s): ${r.ran.join(", ") || "none"})\n`));
|
|
2147
2344
|
out(c.dim(r.ran.length ? `ran ${r.ran.length} job(s): ${r.ran.join(", ")}\n` : "(no jobs due)\n"));
|
|
2148
2345
|
});
|
|
2149
2346
|
cronCmd
|
|
@@ -2207,6 +2404,7 @@ memoryCmd
|
|
|
2207
2404
|
toolFilter: (n) => n === "memory_write" || READONLY_TOOLS.has(n),
|
|
2208
2405
|
systemOverride: MEMORY_DISTILL_SYSTEM,
|
|
2209
2406
|
stats,
|
|
2407
|
+
...agentRunLimits(cfg),
|
|
2210
2408
|
});
|
|
2211
2409
|
if (stats.input || stats.output)
|
|
2212
2410
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
@@ -2484,6 +2682,20 @@ config
|
|
|
2484
2682
|
out(c.red(`Invalid reasoning effort. One of: ${REASONING_EFFORTS.join(", ")}.\n`));
|
|
2485
2683
|
process.exit(1);
|
|
2486
2684
|
}
|
|
2685
|
+
if (key === "runTimeoutMs") {
|
|
2686
|
+
const parsed = parseAgentRunTimeoutMs(value);
|
|
2687
|
+
if (parsed === undefined || parsed < MIN_AGENT_RUN_TIMEOUT_MS || parsed > MAX_AGENT_RUN_TIMEOUT_MS) {
|
|
2688
|
+
out(c.red("Invalid runTimeoutMs. Use 1s..2h, for example `30m`, `90s`, or milliseconds.\n"));
|
|
2689
|
+
process.exit(1);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
if (key === "maxAgentRounds") {
|
|
2693
|
+
const parsed = Number(value);
|
|
2694
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > MAX_AGENT_MAX_ROUNDS) {
|
|
2695
|
+
out(c.red(`Invalid maxAgentRounds. Use an integer from 1 to ${MAX_AGENT_MAX_ROUNDS}.\n`));
|
|
2696
|
+
process.exit(1);
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2487
2699
|
writeConfigValue(key, value);
|
|
2488
2700
|
out(c.green(`Set ${key} → ${configPath()}\n`));
|
|
2489
2701
|
});
|
|
@@ -2502,6 +2714,8 @@ config
|
|
|
2502
2714
|
`baseURL: ${raw.baseURL ?? "(provider default)"}\n` +
|
|
2503
2715
|
`approval: ${raw.approval ?? "(default suggest)"}\n` +
|
|
2504
2716
|
`sandbox: ${raw.sandbox ?? "(default off)"}\n` +
|
|
2717
|
+
`timeout: ${raw.runTimeoutMs ?? "(default 30m)"}\n` +
|
|
2718
|
+
`rounds: ${raw.maxAgentRounds ?? "(default 64)"}\n` +
|
|
2505
2719
|
`apiKey: ${maskKey(raw.apiKey)}\n`);
|
|
2506
2720
|
}
|
|
2507
2721
|
});
|
|
@@ -2539,6 +2753,7 @@ program.action(async (opts) => {
|
|
|
2539
2753
|
}
|
|
2540
2754
|
const cfg = loadConfig({ overlay: opts.overlay, ...(requestedHeadlessAgent?.home ? { cwd: requestedHeadlessAgent.home } : {}) });
|
|
2541
2755
|
const cwd = cfg.cwd;
|
|
2756
|
+
const homeWorkspace = isHomeWorkspace(cwd);
|
|
2542
2757
|
// Resolve the concrete role before constructing any user/plugin MCP transport. MCP servers are arbitrary
|
|
2543
2758
|
// stdio subprocesses, so a read-only persona must not start them merely by launching a turn. Reusing this
|
|
2544
2759
|
// object later also closes the resolve→connect→re-resolve race where a role could disappear or change policy.
|
|
@@ -2670,12 +2885,18 @@ program.action(async (opts) => {
|
|
|
2670
2885
|
// one-shot
|
|
2671
2886
|
if (opts.print) {
|
|
2672
2887
|
let headlessLockId = null;
|
|
2888
|
+
const pendingHeadlessOperations = new Set();
|
|
2889
|
+
const trackHeadlessOperation = (operation) => {
|
|
2890
|
+
pendingHeadlessOperations.add(operation);
|
|
2891
|
+
const settled = () => { pendingHeadlessOperations.delete(operation); };
|
|
2892
|
+
void operation.then(settled, settled);
|
|
2893
|
+
};
|
|
2673
2894
|
try {
|
|
2674
|
-
const projectContext =
|
|
2895
|
+
const projectContext = loadAgentContext(cwd) || undefined;
|
|
2675
2896
|
// Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
|
|
2676
2897
|
// "configure a vision model" even when one is set, leaving a headless agent blind. Mirrors the interactive
|
|
2677
2898
|
// describeScreenshot — a configured visionModel, else the main model if it's vision-capable.
|
|
2678
|
-
const describeImage = async (path, hint) => {
|
|
2899
|
+
const describeImage = async (path, hint, signal) => {
|
|
2679
2900
|
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
2680
2901
|
const vp = cfg.visionModel
|
|
2681
2902
|
? ((await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
|
|
@@ -2685,7 +2906,7 @@ program.action(async (opts) => {
|
|
|
2685
2906
|
if (!vp)
|
|
2686
2907
|
return "";
|
|
2687
2908
|
try {
|
|
2688
|
-
return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
|
|
2909
|
+
return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint, signal });
|
|
2689
2910
|
}
|
|
2690
2911
|
catch {
|
|
2691
2912
|
return "";
|
|
@@ -2824,7 +3045,7 @@ program.action(async (opts) => {
|
|
|
2824
3045
|
// Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
|
|
2825
3046
|
// Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
|
|
2826
3047
|
// visionModel sidecar and folded into the message (text-only models can't take image blocks).
|
|
2827
|
-
const userText =
|
|
3048
|
+
const userText = await expandMentionsAsync(String(opts.print), cwd) + (schemaObj ? STRUCTURED_INSTRUCTION : "");
|
|
2828
3049
|
const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
|
|
2829
3050
|
.split("\n")
|
|
2830
3051
|
.map((s) => s.trim())
|
|
@@ -2880,7 +3101,7 @@ program.action(async (opts) => {
|
|
|
2880
3101
|
let structuredSet = false;
|
|
2881
3102
|
const printRunOpts = {
|
|
2882
3103
|
provider: headlessProvider,
|
|
2883
|
-
ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role), describeImage },
|
|
3104
|
+
ctx: { cwd, sandbox, spawn: (t, role, signal) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role, signal), describeImage },
|
|
2884
3105
|
approval: "full-auto",
|
|
2885
3106
|
confirm: async () => true,
|
|
2886
3107
|
projectContext,
|
|
@@ -2890,6 +3111,9 @@ program.action(async (opts) => {
|
|
|
2890
3111
|
hooks: headlessHooks,
|
|
2891
3112
|
stats,
|
|
2892
3113
|
guardian: guardianOpt, // safety layer stays on in headless -p (fail-open; breaker aborts, never hangs)
|
|
3114
|
+
onProviderTurn: trackHeadlessOperation,
|
|
3115
|
+
onToolRun: trackHeadlessOperation,
|
|
3116
|
+
...agentRunLimits(cfg),
|
|
2893
3117
|
...(schemaObj
|
|
2894
3118
|
? {
|
|
2895
3119
|
extraTools: [structuredOutputTool(schemaObj, (v) => ((structured = v), (structuredSet = true)))],
|
|
@@ -2928,7 +3152,9 @@ program.action(async (opts) => {
|
|
|
2928
3152
|
if (meta) {
|
|
2929
3153
|
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
2930
3154
|
// Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
|
|
2931
|
-
|
|
3155
|
+
if (runOutcome.status === "completed") {
|
|
3156
|
+
await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { });
|
|
3157
|
+
}
|
|
2932
3158
|
saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
|
|
2933
3159
|
}
|
|
2934
3160
|
if (!schemaObj && runOutcome.status === "completed" && (stats.input || stats.output))
|
|
@@ -2937,14 +3163,28 @@ program.action(async (opts) => {
|
|
|
2937
3163
|
return;
|
|
2938
3164
|
}
|
|
2939
3165
|
finally {
|
|
2940
|
-
if (headlessLockId)
|
|
2941
|
-
|
|
3166
|
+
if (headlessLockId) {
|
|
3167
|
+
const lockId = headlessLockId;
|
|
3168
|
+
if (pendingHeadlessOperations.size) {
|
|
3169
|
+
// Do not await here: the logical deadline stays hard. A late operation with active handles keeps
|
|
3170
|
+
// this process (and therefore its cross-process session lease) alive until physical settlement.
|
|
3171
|
+
// A truly inert never-settling Promise has no side effect/handle, so normal process exit makes the
|
|
3172
|
+
// PID-backed lease safely reclaimable.
|
|
3173
|
+
void Promise.allSettled([...pendingHeadlessOperations]).then(() => releaseSessionLock(lockId));
|
|
3174
|
+
}
|
|
3175
|
+
else {
|
|
3176
|
+
releaseSessionLock(lockId);
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
2942
3179
|
await closeMcp();
|
|
2943
3180
|
}
|
|
2944
3181
|
}
|
|
2945
3182
|
// interactive REPL — ink TUI by default on a real terminal; HARA_TUI=0 forces the classic readline path
|
|
2946
3183
|
const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
|
|
2947
3184
|
out(c.bold(`hara ${pkg.version}`) + c.dim(` · ${cfg.provider}:${cfg.model} · ${approval}${sandbox !== "off" ? ` · sandbox:${sandbox}` : ""} · ${cwd}\n`));
|
|
3185
|
+
if (homeWorkspace) {
|
|
3186
|
+
out(c.yellow("⚠ Home directory is not treated as a project workspace. Run `cd /path/to/project`; recursive project scans are disabled here.\n"));
|
|
3187
|
+
}
|
|
2948
3188
|
// Startup update notice — cache-driven (a previous session's background probe), so it costs zero
|
|
2949
3189
|
// latency; today's probe (if due) fires in the background for the NEXT launch. TTY sessions only.
|
|
2950
3190
|
if (cfg.updateCheck && stdout.isTTY) {
|
|
@@ -2964,16 +3204,21 @@ program.action(async (opts) => {
|
|
|
2964
3204
|
return mentionCompleter(line, cwd);
|
|
2965
3205
|
},
|
|
2966
3206
|
});
|
|
2967
|
-
const confirm = async (q) =>
|
|
3207
|
+
const confirm = async (q, signal) => {
|
|
3208
|
+
const prompt = `${q} ${c.dim("[y/N]")} `;
|
|
3209
|
+
const answer = signal ? await rl.question(prompt, { signal }) : await rl.question(prompt);
|
|
3210
|
+
return answer.trim().toLowerCase().startsWith("y");
|
|
3211
|
+
};
|
|
2968
3212
|
// ask_user (classic REPL): print the question + a numbered menu (matching the setup menu look) and read the
|
|
2969
3213
|
// answer through the SAME rl.question channel confirm uses. A bare option number selects it; any other text
|
|
2970
3214
|
// is taken as a free-text answer — so the user can always type their own response.
|
|
2971
|
-
const askUser = async (q, options) => {
|
|
3215
|
+
const askUser = async (q, options, signal) => {
|
|
2972
3216
|
out(c.bold("\n? ") + q + "\n");
|
|
2973
3217
|
const opts = (options ?? []).map((o) => o.trim()).filter(Boolean);
|
|
2974
3218
|
opts.forEach((o, i) => out(` ${c.bold(String(i + 1))}) ${o}\n`));
|
|
2975
3219
|
const hint = opts.length ? c.dim(`(1-${opts.length} to pick, or type your own answer) `) : c.dim("(type your answer) ");
|
|
2976
|
-
const
|
|
3220
|
+
const prompt = `${c.cyan("›")} ${hint}`;
|
|
3221
|
+
const raw = (signal ? await rl.question(prompt, { signal }) : await rl.question(prompt)).trim();
|
|
2977
3222
|
if (opts.length) {
|
|
2978
3223
|
const n = Number.parseInt(raw, 10);
|
|
2979
3224
|
if (Number.isInteger(n) && n >= 1 && n <= opts.length)
|
|
@@ -3004,20 +3249,20 @@ program.action(async (opts) => {
|
|
|
3004
3249
|
// First-run AGENTS.md offer — classic REPL only. In TUI mode we must NOT call rl.question before ink
|
|
3005
3250
|
// mounts: a readline question puts stdin in a state ink can't read from, leaving the input box dead
|
|
3006
3251
|
// (the TUI shows a `/init` tip instead, below). See the `tip` in the runTui header.
|
|
3007
|
-
if (!hasAgentsMd(cwd) && !useTui) {
|
|
3252
|
+
if (!homeWorkspace && !hasAgentsMd(cwd) && !useTui) {
|
|
3008
3253
|
const ans = (await rl.question(`${c.dim("No AGENTS.md here — analyze this project and create one?")} ${c.dim("[Y/n]")} `)).trim().toLowerCase();
|
|
3009
3254
|
if (ans === "" || ans.startsWith("y")) {
|
|
3010
3255
|
out(c.dim("Analyzing project…\n"));
|
|
3011
3256
|
try {
|
|
3012
|
-
await runInit(provider, cwd, sandbox);
|
|
3257
|
+
await runInit(provider, cwd, sandbox, cfg);
|
|
3013
3258
|
}
|
|
3014
3259
|
catch (e) {
|
|
3015
3260
|
out(c.red(`[init error] ${e.message}\n`));
|
|
3016
3261
|
}
|
|
3017
3262
|
}
|
|
3018
3263
|
}
|
|
3019
|
-
let projectContext =
|
|
3020
|
-
const spawn = (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role);
|
|
3264
|
+
let projectContext = loadAgentContext(cwd) || undefined;
|
|
3265
|
+
const spawn = (t, role, signal) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role, signal);
|
|
3021
3266
|
// session: --resume <id> / --continue (latest in this cwd) / new
|
|
3022
3267
|
let resumeId = null;
|
|
3023
3268
|
if (opts.resume) {
|
|
@@ -3147,8 +3392,8 @@ program.action(async (opts) => {
|
|
|
3147
3392
|
run: async () => {
|
|
3148
3393
|
out(c.dim("Analyzing project…\n"));
|
|
3149
3394
|
try {
|
|
3150
|
-
await runInit(provider, cwd, sandbox);
|
|
3151
|
-
projectContext =
|
|
3395
|
+
await runInit(provider, cwd, sandbox, cfg);
|
|
3396
|
+
projectContext = loadAgentContext(cwd) || undefined;
|
|
3152
3397
|
out(c.green("AGENTS.md updated.\n"));
|
|
3153
3398
|
}
|
|
3154
3399
|
catch (e) {
|
|
@@ -3367,7 +3612,16 @@ program.action(async (opts) => {
|
|
|
3367
3612
|
desc: "summarize the conversation so far to free up context",
|
|
3368
3613
|
run: async () => {
|
|
3369
3614
|
out(c.dim("Compacting…\n"));
|
|
3370
|
-
const
|
|
3615
|
+
const compactTurn = new AbortController();
|
|
3616
|
+
currentTurn = compactTurn;
|
|
3617
|
+
let summary = null;
|
|
3618
|
+
try {
|
|
3619
|
+
summary = await compactConversation(provider, history, meta, stats, compactTurn.signal);
|
|
3620
|
+
}
|
|
3621
|
+
finally {
|
|
3622
|
+
if (currentTurn === compactTurn)
|
|
3623
|
+
currentTurn = null;
|
|
3624
|
+
}
|
|
3371
3625
|
out(summary ? c.green(`(compacted — ${summary.length} chars; context replaced with the summary)\n`) : c.dim("(nothing to compact / compact failed)\n"));
|
|
3372
3626
|
},
|
|
3373
3627
|
},
|
|
@@ -3427,16 +3681,16 @@ program.action(async (opts) => {
|
|
|
3427
3681
|
// First-run AGENTS.md offer — via a tiny ink prompt, NOT readline. A readline question before the
|
|
3428
3682
|
// main TUI leaves stdin unreadable by ink (dead input box); ink cleans up on unmount, so the TUI
|
|
3429
3683
|
// mounted right after gets working input. Runs before mount, like the classic path.
|
|
3430
|
-
if (!hasAgentsMd(cwd)) {
|
|
3684
|
+
if (!homeWorkspace && !hasAgentsMd(cwd)) {
|
|
3431
3685
|
if (await askConfirm("No AGENTS.md here — analyze this project and create one?")) {
|
|
3432
3686
|
out(c.dim("Analyzing project…\n"));
|
|
3433
3687
|
try {
|
|
3434
|
-
await runInit(provider, cwd, sandbox);
|
|
3688
|
+
await runInit(provider, cwd, sandbox, cfg);
|
|
3435
3689
|
}
|
|
3436
3690
|
catch (e) {
|
|
3437
3691
|
out(c.red(`[init error] ${e.message}\n`));
|
|
3438
3692
|
}
|
|
3439
|
-
projectContext =
|
|
3693
|
+
projectContext = loadAgentContext(cwd) || undefined;
|
|
3440
3694
|
}
|
|
3441
3695
|
}
|
|
3442
3696
|
setTheme(cfg.theme);
|
|
@@ -3452,13 +3706,13 @@ program.action(async (opts) => {
|
|
|
3452
3706
|
// lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model).
|
|
3453
3707
|
// Uses the screenshot-tuned prompt (actionable UI elements + positions) + an optional focus hint, so a
|
|
3454
3708
|
// text-only main model gets something it can click on rather than a generic transcription.
|
|
3455
|
-
const describeScreenshot = async (path, hint) => {
|
|
3709
|
+
const describeScreenshot = async (path, hint, signal) => {
|
|
3456
3710
|
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
3457
3711
|
const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
|
|
3458
3712
|
if (!vp)
|
|
3459
3713
|
return "";
|
|
3460
3714
|
try {
|
|
3461
|
-
return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
|
|
3715
|
+
return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint, signal });
|
|
3462
3716
|
}
|
|
3463
3717
|
catch {
|
|
3464
3718
|
return "";
|
|
@@ -3466,13 +3720,13 @@ program.action(async (opts) => {
|
|
|
3466
3720
|
};
|
|
3467
3721
|
// grounding for accurate RPA: ask the vision model WHERE an element is (0..1 fractions) so the computer
|
|
3468
3722
|
// tool can click it precisely instead of guessing pixels from a text description.
|
|
3469
|
-
const locateScreenshot = async (path, target) => {
|
|
3723
|
+
const locateScreenshot = async (path, target, signal) => {
|
|
3470
3724
|
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
3471
3725
|
const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
|
|
3472
3726
|
if (!vp)
|
|
3473
3727
|
return null;
|
|
3474
3728
|
try {
|
|
3475
|
-
return await locateImage(vp, { path, mediaType: "image/png" }, target);
|
|
3729
|
+
return await locateImage(vp, { path, mediaType: "image/png" }, target, { signal });
|
|
3476
3730
|
}
|
|
3477
3731
|
catch {
|
|
3478
3732
|
return null;
|
|
@@ -3558,7 +3812,7 @@ program.action(async (opts) => {
|
|
|
3558
3812
|
version: pkg.version,
|
|
3559
3813
|
modelLabel: `${cfg.provider}:${cfg.model}`,
|
|
3560
3814
|
cwd,
|
|
3561
|
-
agentsMdLoaded:
|
|
3815
|
+
agentsMdLoaded: hasAgentsMd(cwd),
|
|
3562
3816
|
session: meta.id,
|
|
3563
3817
|
kind: __activeP.kind === "gateway" ? "org" : "personal",
|
|
3564
3818
|
profileId: __activeP.kind === "gateway" || __activeP.id === PERSONAL_ID ? undefined : __activeP.id,
|
|
@@ -3570,6 +3824,9 @@ program.action(async (opts) => {
|
|
|
3570
3824
|
// the pre-mount stdout notice (line ~2497) doesn't survive ink taking the screen — TUI users
|
|
3571
3825
|
// never saw update notices and versions silently went stale (field report: stuck on 0.112.5)
|
|
3572
3826
|
updateNotice: cfg.updateCheck ? (checkForUpdate(pkg.version) ?? undefined) : undefined,
|
|
3827
|
+
workspaceNotice: homeWorkspace
|
|
3828
|
+
? "Home is not a project workspace · cd /path/to/project (recursive scans disabled here)"
|
|
3829
|
+
: undefined,
|
|
3573
3830
|
},
|
|
3574
3831
|
visionNotice: __visionNotice,
|
|
3575
3832
|
cycleApproval: (m) => cycleMode(m),
|
|
@@ -3595,6 +3852,7 @@ program.action(async (opts) => {
|
|
|
3595
3852
|
memory: buildMemory(),
|
|
3596
3853
|
stats,
|
|
3597
3854
|
signal: h.signal,
|
|
3855
|
+
...agentRunLimits(cfg),
|
|
3598
3856
|
});
|
|
3599
3857
|
saveSession(meta, history);
|
|
3600
3858
|
}
|
|
@@ -3713,9 +3971,10 @@ program.action(async (opts) => {
|
|
|
3713
3971
|
return void h.sink.notice("(nothing to compact)");
|
|
3714
3972
|
h.sink.notice("✻ compacting…");
|
|
3715
3973
|
const cui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
3974
|
+
let distillOutcome;
|
|
3716
3975
|
if (cfg.evolve !== "off") {
|
|
3717
3976
|
try {
|
|
3718
|
-
await runAgent(history, {
|
|
3977
|
+
distillOutcome = await runAgent(history, {
|
|
3719
3978
|
provider,
|
|
3720
3979
|
ctx: { cwd, sandbox, spawn, ui: cui },
|
|
3721
3980
|
approval: cfg.assetCapture === "auto" ? "full-auto" : "suggest", // ask → prompt before each save; auto → silent
|
|
@@ -3725,13 +3984,17 @@ program.action(async (opts) => {
|
|
|
3725
3984
|
memory: buildMemory(),
|
|
3726
3985
|
stats,
|
|
3727
3986
|
signal: h.signal,
|
|
3987
|
+
...agentRunLimits(cfg),
|
|
3728
3988
|
});
|
|
3729
3989
|
}
|
|
3730
3990
|
catch {
|
|
3731
|
-
|
|
3991
|
+
return void h.sink.notice("(compact cancelled — memory distillation did not complete)");
|
|
3732
3992
|
}
|
|
3733
3993
|
}
|
|
3734
|
-
|
|
3994
|
+
if (h.signal.aborted || (distillOutcome && distillOutcome.status !== "completed")) {
|
|
3995
|
+
return void h.sink.notice("(compact cancelled — memory distillation was interrupted)");
|
|
3996
|
+
}
|
|
3997
|
+
const summary = await compactConversation(provider, history, meta, stats, h.signal);
|
|
3735
3998
|
return void h.sink.notice(summary ? `(compacted — kept ${meta.workingSet?.length ?? 0} working-memory notes)` : "(nothing to compact / compact failed)");
|
|
3736
3999
|
}
|
|
3737
4000
|
if (nm === "sessions") {
|
|
@@ -3790,7 +4053,7 @@ program.action(async (opts) => {
|
|
|
3790
4053
|
}
|
|
3791
4054
|
if (nm === "commit") {
|
|
3792
4055
|
h.sink.notice("✻ writing a commit message…");
|
|
3793
|
-
const r = await autoCommit(provider, cwd); // stages all + commits with an AI message
|
|
4056
|
+
const r = await autoCommit(provider, cwd, h.signal); // stages all + commits with an AI message
|
|
3794
4057
|
return void h.sink.notice(r.startsWith("error:") ? `✗ ${r}` : r === "nothing to commit" ? "(nothing to commit — make or stage changes first)" : `✓ committed · ${r.slice(0, 100)}`);
|
|
3795
4058
|
}
|
|
3796
4059
|
if (nm === "review") {
|
|
@@ -3817,6 +4080,7 @@ program.action(async (opts) => {
|
|
|
3817
4080
|
memory: buildMemory(),
|
|
3818
4081
|
stats,
|
|
3819
4082
|
signal: h.signal,
|
|
4083
|
+
...agentRunLimits(cfg),
|
|
3820
4084
|
});
|
|
3821
4085
|
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
3822
4086
|
return;
|
|
@@ -3839,14 +4103,15 @@ program.action(async (opts) => {
|
|
|
3839
4103
|
// ApprovalMode (no "plan"). Inside a /<skill> kickoff "plan" wouldn't make sense anyway —
|
|
3840
4104
|
// fall back to "suggest" so we keep the user's confirm gate without crashing the type check.
|
|
3841
4105
|
const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
|
|
4106
|
+
let skillOutcome;
|
|
3842
4107
|
try {
|
|
3843
|
-
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt });
|
|
4108
|
+
skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
3844
4109
|
}
|
|
3845
4110
|
catch (e) {
|
|
3846
4111
|
h.sink.notice(`[error] ${e?.message ?? e}`);
|
|
3847
4112
|
}
|
|
3848
|
-
if (!meta.title) {
|
|
3849
|
-
meta.title = await nameSession(provider, history);
|
|
4113
|
+
if (!meta.title && skillOutcome?.status === "completed" && !h.signal.aborted) {
|
|
4114
|
+
meta.title = await nameSession(provider, history, h.signal);
|
|
3850
4115
|
h.sink.session(meta.title);
|
|
3851
4116
|
}
|
|
3852
4117
|
h.sink.usage(stats.input - skin, stats.output - skout);
|
|
@@ -3869,7 +4134,7 @@ program.action(async (opts) => {
|
|
|
3869
4134
|
const out = [];
|
|
3870
4135
|
for (const it of h.drainQueue()) {
|
|
3871
4136
|
const r2 = await resolveImages(it.images, h);
|
|
3872
|
-
const body =
|
|
4137
|
+
const body = await expandMentionsAsync(it.line, cwd, { signal: h.signal }) + (r2.skip ? "" : (r2.extraText ?? ""));
|
|
3873
4138
|
const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
|
|
3874
4139
|
if (!body.trim() && !attach)
|
|
3875
4140
|
continue; // image-only message whose image was skipped → nothing to add
|
|
@@ -3885,7 +4150,7 @@ program.action(async (opts) => {
|
|
|
3885
4150
|
const planImg = await resolveImages(images, h);
|
|
3886
4151
|
if (planImg.skip)
|
|
3887
4152
|
return;
|
|
3888
|
-
history.push({ role: "user", content: (recalledContext ? `${recalledContext}\n\n---\n\n` : "") +
|
|
4153
|
+
history.push({ role: "user", content: (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (planImg.extraText ?? ""), ...(planImg.attach?.length ? { images: planImg.attach } : {}) });
|
|
3889
4154
|
recalledContext = "";
|
|
3890
4155
|
const pin = stats.input;
|
|
3891
4156
|
const pout = stats.output;
|
|
@@ -3905,7 +4170,7 @@ program.action(async (opts) => {
|
|
|
3905
4170
|
return "Plan submitted to the user for approval. Stop now and wait for their decision — do not keep working.";
|
|
3906
4171
|
},
|
|
3907
4172
|
};
|
|
3908
|
-
await runAgent(history, {
|
|
4173
|
+
let planOutcome = await runAgent(history, {
|
|
3909
4174
|
provider,
|
|
3910
4175
|
ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
3911
4176
|
approval: "suggest",
|
|
@@ -3919,16 +4184,28 @@ program.action(async (opts) => {
|
|
|
3919
4184
|
stats,
|
|
3920
4185
|
signal: h.signal,
|
|
3921
4186
|
pendingInput,
|
|
4187
|
+
...agentRunLimits(cfg),
|
|
3922
4188
|
});
|
|
3923
|
-
if (!meta.title) {
|
|
3924
|
-
meta.title = await nameSession(provider, history);
|
|
4189
|
+
if (!meta.title && planOutcome.status === "completed" && !h.signal.aborted) {
|
|
4190
|
+
meta.title = await nameSession(provider, history, h.signal);
|
|
3925
4191
|
h.sink.session(meta.title);
|
|
3926
4192
|
}
|
|
3927
4193
|
h.sink.usage(stats.input - pin, stats.output - pout);
|
|
3928
4194
|
saveSession(meta, history);
|
|
4195
|
+
if (planOutcome.status !== "completed" || h.signal.aborted) {
|
|
4196
|
+
notifyDone(cfg.notify, {
|
|
4197
|
+
message: planOutcome.error ?? "plan turn did not complete",
|
|
4198
|
+
elapsedMs: Date.now() - turnStart,
|
|
4199
|
+
minMs: 0,
|
|
4200
|
+
});
|
|
4201
|
+
return;
|
|
4202
|
+
}
|
|
3929
4203
|
if (!proposedPlan) {
|
|
3930
4204
|
// No exit_plan this turn — the model was investigating or answering. Stay in plan mode quietly.
|
|
3931
|
-
notifyDone(cfg.notify, {
|
|
4205
|
+
notifyDone(cfg.notify, {
|
|
4206
|
+
message: meta.title || "plan turn complete",
|
|
4207
|
+
elapsedMs: Date.now() - turnStart,
|
|
4208
|
+
});
|
|
3932
4209
|
return;
|
|
3933
4210
|
}
|
|
3934
4211
|
const choice = await h.select("hara has a plan — proceed?", [
|
|
@@ -3941,7 +4218,7 @@ program.action(async (opts) => {
|
|
|
3941
4218
|
history.push({ role: "user", content: "Proceed: execute the plan above." });
|
|
3942
4219
|
const xin = stats.input;
|
|
3943
4220
|
const xout = stats.output;
|
|
3944
|
-
await runAgent(history, {
|
|
4221
|
+
planOutcome = await runAgent(history, {
|
|
3945
4222
|
provider,
|
|
3946
4223
|
ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
3947
4224
|
approval: choice,
|
|
@@ -3953,24 +4230,29 @@ program.action(async (opts) => {
|
|
|
3953
4230
|
signal: h.signal,
|
|
3954
4231
|
pendingInput,
|
|
3955
4232
|
guardian: guardianOpt,
|
|
4233
|
+
...agentRunLimits(cfg),
|
|
3956
4234
|
});
|
|
3957
4235
|
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
3958
4236
|
saveSession(meta, history);
|
|
3959
4237
|
}
|
|
3960
|
-
notifyDone(cfg.notify, {
|
|
4238
|
+
notifyDone(cfg.notify, {
|
|
4239
|
+
message: planOutcome.status === "halted" ? (planOutcome.error ?? "agent run halted") : (meta.title || "plan turn complete"),
|
|
4240
|
+
elapsedMs: Date.now() - turnStart,
|
|
4241
|
+
minMs: planOutcome.status === "halted" ? 0 : undefined,
|
|
4242
|
+
});
|
|
3961
4243
|
return;
|
|
3962
4244
|
}
|
|
3963
4245
|
const ri = await resolveImages(images, h);
|
|
3964
4246
|
if (ri.skip)
|
|
3965
4247
|
return;
|
|
3966
|
-
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") +
|
|
4248
|
+
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (ri.extraText ?? "");
|
|
3967
4249
|
recalledContext = "";
|
|
3968
4250
|
history.push({ role: "user", content: userContent, ...(ri.attach?.length ? { images: ri.attach } : {}) });
|
|
3969
4251
|
if (cfg.fileCheckpoints)
|
|
3970
4252
|
checkpoint(cwd, line.slice(0, 80)); // shadow-git snapshot before the turn mutates
|
|
3971
4253
|
const beforeIn = stats.input;
|
|
3972
4254
|
const beforeOut = stats.output;
|
|
3973
|
-
await runAgent(history, {
|
|
4255
|
+
const turnOutcome = await runAgent(history, {
|
|
3974
4256
|
provider,
|
|
3975
4257
|
ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
3976
4258
|
approval: appr,
|
|
@@ -3983,15 +4265,22 @@ program.action(async (opts) => {
|
|
|
3983
4265
|
pendingInput,
|
|
3984
4266
|
fallback: fbOpt,
|
|
3985
4267
|
guardian: guardianOpt,
|
|
4268
|
+
...agentRunLimits(cfg),
|
|
3986
4269
|
});
|
|
3987
|
-
if (!meta.title) {
|
|
3988
|
-
meta.title = await nameSession(provider, history);
|
|
4270
|
+
if (!meta.title && turnOutcome.status === "completed" && !h.signal.aborted) {
|
|
4271
|
+
meta.title = await nameSession(provider, history, h.signal);
|
|
3989
4272
|
h.sink.session(meta.title);
|
|
3990
4273
|
}
|
|
3991
4274
|
h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
|
|
3992
|
-
notifyDone(cfg.notify, {
|
|
4275
|
+
notifyDone(cfg.notify, {
|
|
4276
|
+
message: turnOutcome.status === "halted" ? (turnOutcome.error ?? "agent run halted") : (meta.title || "turn complete"),
|
|
4277
|
+
elapsedMs: Date.now() - turnStart,
|
|
4278
|
+
minMs: turnOutcome.status === "halted" ? 0 : undefined,
|
|
4279
|
+
});
|
|
3993
4280
|
saveSession(meta, history);
|
|
3994
|
-
|
|
4281
|
+
if (turnOutcome.status === "completed" && !h.signal.aborted) {
|
|
4282
|
+
await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m), h.signal);
|
|
4283
|
+
}
|
|
3995
4284
|
},
|
|
3996
4285
|
});
|
|
3997
4286
|
// Only claim "saved · resume" if a turn actually persisted the session. A zero-turn session (opened,
|
|
@@ -4002,7 +4291,7 @@ program.action(async (opts) => {
|
|
|
4002
4291
|
await closeMcp();
|
|
4003
4292
|
process.exit(0); // TUI done — exit cleanly (ink can leave stdin referenced)
|
|
4004
4293
|
}
|
|
4005
|
-
out(c.dim(`Type a task. /help · @path attaches a file · shift+tab cycles mode · Esc interrupts · /exit to quit.${
|
|
4294
|
+
out(c.dim(`Type a task. /help · @path attaches a file · shift+tab cycles mode · Esc interrupts · /exit to quit.${hasAgentsMd(cwd) ? " (AGENTS.md loaded)" : ""}\n\n`));
|
|
4006
4295
|
bar.install({ sessionName: meta.title || shortId(meta.id), model: cfg.model, approval, input: stats.input, output: stats.output, profileId: __activeP.id, profileKind: __activeP.kind });
|
|
4007
4296
|
process.on("exit", () => {
|
|
4008
4297
|
try {
|
|
@@ -4038,19 +4327,25 @@ program.action(async (opts) => {
|
|
|
4038
4327
|
role: "user",
|
|
4039
4328
|
content: `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${rest.length ? ` — request: ${rest.join(" ")}` : ""}. Follow this skill now. If it has a workspace or live preview, OPEN it FIRST so any existing progress is visible, then proceed — offer to continue existing work or start fresh.`,
|
|
4040
4329
|
});
|
|
4041
|
-
|
|
4330
|
+
const skillTurn = new AbortController();
|
|
4331
|
+
currentTurn = skillTurn;
|
|
4332
|
+
let skillOutcome;
|
|
4042
4333
|
try {
|
|
4043
|
-
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal:
|
|
4334
|
+
skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: skillTurn.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4044
4335
|
}
|
|
4045
4336
|
catch (e) {
|
|
4046
4337
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
4047
4338
|
}
|
|
4339
|
+
try {
|
|
4340
|
+
if (!meta.title && skillOutcome?.status === "completed" && !skillTurn.signal.aborted) {
|
|
4341
|
+
meta.title = await nameSession(provider, history, skillTurn.signal);
|
|
4342
|
+
}
|
|
4343
|
+
saveSession(meta, history);
|
|
4344
|
+
}
|
|
4048
4345
|
finally {
|
|
4049
|
-
currentTurn
|
|
4346
|
+
if (currentTurn === skillTurn)
|
|
4347
|
+
currentTurn = null;
|
|
4050
4348
|
}
|
|
4051
|
-
if (!meta.title)
|
|
4052
|
-
meta.title = await nameSession(provider, history);
|
|
4053
|
-
saveSession(meta, history);
|
|
4054
4349
|
continue;
|
|
4055
4350
|
}
|
|
4056
4351
|
const near = nearest(name, [...byName.keys()]);
|
|
@@ -4064,41 +4359,54 @@ program.action(async (opts) => {
|
|
|
4064
4359
|
continue;
|
|
4065
4360
|
}
|
|
4066
4361
|
line = inlineLeadingPath(line, existsSync); // leading dropped file path → @-mention so it's read in
|
|
4067
|
-
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") +
|
|
4362
|
+
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd);
|
|
4068
4363
|
recalledContext = "";
|
|
4069
4364
|
history.push({ role: "user", content: userContent });
|
|
4070
4365
|
if (cfg.fileCheckpoints)
|
|
4071
4366
|
checkpoint(cwd, userContent.slice(0, 80)); // shadow-git snapshot before the turn mutates
|
|
4072
|
-
|
|
4367
|
+
const turnController = new AbortController();
|
|
4368
|
+
currentTurn = turnController;
|
|
4073
4369
|
const t0 = Date.now();
|
|
4370
|
+
let turnOutcome;
|
|
4074
4371
|
try {
|
|
4075
|
-
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal:
|
|
4372
|
+
turnOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: turnController.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4076
4373
|
}
|
|
4077
4374
|
catch (e) {
|
|
4078
4375
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
4079
4376
|
}
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4377
|
+
notifyDone(cfg.notify, {
|
|
4378
|
+
message: turnOutcome?.status === "halted" ? (turnOutcome.error ?? "agent run halted") : (meta.title || "turn complete"),
|
|
4379
|
+
elapsedMs: Date.now() - t0,
|
|
4380
|
+
minMs: turnOutcome?.status === "halted" ? 0 : undefined,
|
|
4381
|
+
});
|
|
4382
|
+
try {
|
|
4383
|
+
if (!meta.title && turnOutcome?.status === "completed" && !turnController.signal.aborted) {
|
|
4384
|
+
meta.title = await nameSession(provider, history, turnController.signal);
|
|
4385
|
+
}
|
|
4386
|
+
if (bar.isActive()) {
|
|
4387
|
+
bar.update({
|
|
4388
|
+
sessionName: meta.title,
|
|
4389
|
+
input: stats.input,
|
|
4390
|
+
output: stats.output,
|
|
4391
|
+
ctxPct: bar.ctxPctFor(cfg.model, stats.lastInput ?? 0),
|
|
4392
|
+
});
|
|
4393
|
+
}
|
|
4394
|
+
else {
|
|
4395
|
+
out(statusLine(cfg.model, stats.input, stats.output) + "\n\n");
|
|
4396
|
+
}
|
|
4397
|
+
saveSession(meta, history);
|
|
4398
|
+
const compacted = turnOutcome?.status === "completed" && !turnController.signal.aborted
|
|
4399
|
+
? await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => out(c.dim(`${m}\n`)), turnController.signal)
|
|
4400
|
+
: false;
|
|
4401
|
+
if (!compacted) {
|
|
4402
|
+
const ctxPct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
|
|
4403
|
+
if (ctxPct >= 80)
|
|
4404
|
+
out(c.yellow(` ⚠ context ${ctxPct}% full — /compact to summarize, or /clear to reset\n`));
|
|
4405
|
+
}
|
|
4096
4406
|
}
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
if (ctxPct >= 80)
|
|
4101
|
-
out(c.yellow(` ⚠ context ${ctxPct}% full — /compact to summarize, or /clear to reset\n`));
|
|
4407
|
+
finally {
|
|
4408
|
+
if (currentTurn === turnController)
|
|
4409
|
+
currentTurn = null;
|
|
4102
4410
|
}
|
|
4103
4411
|
}
|
|
4104
4412
|
bar.uninstall();
|