@zhongqian97-code/ecode 0.3.11 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +44 -0
  2. package/dist/index.js +673 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -154,6 +154,50 @@ Type `/` to see available skills with Tab autocomplete. Skills inject structured
154
154
 
155
155
  Run `/setup-matt-pocock-skills` once to configure the issue tracker and triage vocabulary for your repo.
156
156
 
157
+ ## Automation: /loop and /goal
158
+
159
+ Run LLM tasks automatically — on a schedule or until a condition is met.
160
+
161
+ ### /loop — recurring tasks
162
+
163
+ ```
164
+ /loop [interval] <prompt>
165
+ ```
166
+
167
+ Runs the prompt on a fixed interval. Interval formats: `30s`, `5m`, `1h`, `1h30m`. Defaults to 5 minutes if omitted.
168
+
169
+ ```
170
+ /loop 10m check git status and summarize uncommitted changes
171
+ /loop 1h summarize today's work and update CHANGELOG
172
+ ```
173
+
174
+ ### /goal — condition-driven tasks
175
+
176
+ ```
177
+ /goal <condition>
178
+ ```
179
+
180
+ Runs the condition as both the prompt and the success criterion. The LLM executes the prompt, then a separate evaluator LLM judges whether the condition has been met. Repeats until `done` or `blocked`.
181
+
182
+ ```
183
+ /goal all TypeScript errors are fixed
184
+ /goal the test suite passes with no failures
185
+ ```
186
+
187
+ ### Managing jobs
188
+
189
+ ```
190
+ /jobs — list all active jobs
191
+ /unloop <id> — cancel a loop job (use first 8 chars of id)
192
+ /ungoal <id> — cancel a goal job
193
+ ```
194
+
195
+ ### Data storage
196
+
197
+ Job state and run logs are stored in `<ECODE_LOG_DIR>/automation/` (or `~/.config/ecode/automation/` if no log dir is set). Jobs survive restarts — they resume automatically on next launch.
198
+
199
+ ---
200
+
157
201
  ## Session logging
158
202
 
159
203
  Enable JSONL session logs to replay or analyze conversations:
package/dist/index.js CHANGED
@@ -2100,6 +2100,13 @@ function confirmSelection(inputText, selectedPath) {
2100
2100
 
2101
2101
  // src/ui/mouseInput.ts
2102
2102
  var SGR_MOUSE_RE = /^\x1b\[<(\d+);\d+;\d+[Mm]/;
2103
+ function isMouseEvent(data) {
2104
+ const s = typeof data === "string" ? data : data.toString("binary");
2105
+ if (!s) return false;
2106
+ if (SGR_MOUSE_RE.test(s)) return true;
2107
+ if (s.length >= 6 && s.charCodeAt(0) === 27 && s[1] === "[" && s[2] === "M") return true;
2108
+ return false;
2109
+ }
2103
2110
  function parseMouseScroll(data) {
2104
2111
  const s = typeof data === "string" ? data : data.toString("binary");
2105
2112
  if (!s) return null;
@@ -2119,8 +2126,635 @@ function parseMouseScroll(data) {
2119
2126
  return null;
2120
2127
  }
2121
2128
 
2129
+ // src/ui/App.tsx
2130
+ import { homedir as homedir2 } from "os";
2131
+ import { join as join9 } from "path";
2132
+
2133
+ // src/automation/store.ts
2134
+ import { readFile as readFile8, writeFile as writeFile5, mkdir as mkdir2 } from "fs/promises";
2135
+ import { join as join7 } from "path";
2136
+ function jobsFilePath(dataDir) {
2137
+ return join7(dataDir, "jobs.json");
2138
+ }
2139
+ async function loadJobs(dataDir) {
2140
+ try {
2141
+ const content = await readFile8(jobsFilePath(dataDir), "utf-8");
2142
+ const parsed = JSON.parse(content);
2143
+ if (!Array.isArray(parsed)) return [];
2144
+ return parsed;
2145
+ } catch {
2146
+ return [];
2147
+ }
2148
+ }
2149
+ async function saveJobs(dataDir, jobs) {
2150
+ await mkdir2(dataDir, { recursive: true });
2151
+ await writeFile5(jobsFilePath(dataDir), JSON.stringify(jobs, null, 2), "utf-8");
2152
+ }
2153
+ async function upsertJob(dataDir, job) {
2154
+ const jobs = await loadJobs(dataDir);
2155
+ const idx = jobs.findIndex((j) => j.id === job.id);
2156
+ if (idx >= 0) {
2157
+ const updated = [...jobs.slice(0, idx), job, ...jobs.slice(idx + 1)];
2158
+ await saveJobs(dataDir, updated);
2159
+ } else {
2160
+ await saveJobs(dataDir, [...jobs, job]);
2161
+ }
2162
+ }
2163
+ async function removeJob(dataDir, id) {
2164
+ const jobs = await loadJobs(dataDir);
2165
+ const target = jobs.find((j) => j.id === id);
2166
+ if (!target) return void 0;
2167
+ await saveJobs(dataDir, jobs.filter((j) => j.id !== id));
2168
+ return target;
2169
+ }
2170
+
2171
+ // src/automation/runtime.ts
2172
+ import { randomUUID } from "crypto";
2173
+
2174
+ // src/automation/log.ts
2175
+ import { readFile as readFile9, appendFile, mkdir as mkdir3 } from "fs/promises";
2176
+ import { join as join8 } from "path";
2177
+ function logFilePath(logDir) {
2178
+ return join8(logDir, "automation-runs.jsonl");
2179
+ }
2180
+ async function appendRunLog(logDir, entry) {
2181
+ await mkdir3(logDir, { recursive: true });
2182
+ await appendFile(logFilePath(logDir), JSON.stringify(entry) + "\n", "utf-8");
2183
+ }
2184
+
2185
+ // src/automation/runtime.ts
2186
+ async function executeJob(job, config2) {
2187
+ if (job.budget?.maxTurns !== void 0 && job.runCount >= job.budget.maxTurns) {
2188
+ return null;
2189
+ }
2190
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2191
+ const runId = randomUUID();
2192
+ let summaryText;
2193
+ let errorMsg;
2194
+ let result;
2195
+ try {
2196
+ summaryText = await config2.session.run(job.promptTemplate);
2197
+ result = "success";
2198
+ } catch (err) {
2199
+ errorMsg = err instanceof Error ? err.message : String(err);
2200
+ result = "failure";
2201
+ }
2202
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
2203
+ const entry = {
2204
+ id: runId,
2205
+ jobId: job.id,
2206
+ startedAt,
2207
+ finishedAt,
2208
+ promptUsed: job.promptTemplate,
2209
+ result,
2210
+ summaryText,
2211
+ error: errorMsg
2212
+ };
2213
+ await appendRunLog(config2.logDir, entry);
2214
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2215
+ const updatedJob = {
2216
+ ...job,
2217
+ runCount: job.runCount + 1,
2218
+ failureCount: result === "failure" ? job.failureCount + 1 : job.failureCount,
2219
+ lastRunAt: finishedAt,
2220
+ lastError: errorMsg,
2221
+ updatedAt: now
2222
+ };
2223
+ await upsertJob(config2.dataDir, updatedJob);
2224
+ return entry;
2225
+ }
2226
+
2227
+ // src/automation/goal/evaluator.ts
2228
+ function parseVerdict(response) {
2229
+ const lower = response.toLowerCase();
2230
+ if (lower.includes("not_done")) return "not_done";
2231
+ if (lower.includes("blocked")) return "blocked";
2232
+ if (lower.includes("done")) return "done";
2233
+ return "not_done";
2234
+ }
2235
+ function buildPrompt(input) {
2236
+ return [
2237
+ "You are evaluating whether a goal condition has been met based on a work transcript.",
2238
+ "",
2239
+ `Goal condition: ${input.condition}`,
2240
+ "",
2241
+ "Work transcript:",
2242
+ input.transcript,
2243
+ "",
2244
+ 'Reply with one of these verdicts: "done", "not_done", or "blocked".',
2245
+ '"done" means the condition is satisfied.',
2246
+ '"not_done" means the condition is not yet satisfied but work can continue.',
2247
+ '"blocked" means progress is impossible due to an error or blocker.',
2248
+ "After the verdict, explain your reasoning briefly."
2249
+ ].join("\n");
2250
+ }
2251
+ function createLLMEvaluator(session) {
2252
+ return {
2253
+ async evaluate(input) {
2254
+ try {
2255
+ const prompt = buildPrompt(input);
2256
+ const response = await session.run(prompt);
2257
+ const verdict = parseVerdict(response);
2258
+ return { verdict, reason: response };
2259
+ } catch (error) {
2260
+ const message = error instanceof Error ? error.message : String(error);
2261
+ return { verdict: "blocked", reason: message };
2262
+ }
2263
+ }
2264
+ };
2265
+ }
2266
+
2267
+ // src/automation/loop/scheduler.ts
2268
+ var LoopScheduler = class {
2269
+ options;
2270
+ /** 按 jobId 索引的调度表 */
2271
+ entries = /* @__PURE__ */ new Map();
2272
+ constructor(options) {
2273
+ this.options = options;
2274
+ }
2275
+ /**
2276
+ * 初始化调度器,传入初始 job 列表
2277
+ * 每个 job 会被立即纳入调度
2278
+ */
2279
+ start(jobs) {
2280
+ for (const job of jobs) {
2281
+ this.scheduleJob(job);
2282
+ }
2283
+ }
2284
+ /**
2285
+ * 停止所有调度,清除全部挂起的 timer
2286
+ */
2287
+ stop() {
2288
+ for (const entry of this.entries.values()) {
2289
+ if (entry.timerId !== void 0) {
2290
+ clearTimeout(entry.timerId);
2291
+ entry.timerId = void 0;
2292
+ }
2293
+ }
2294
+ this.entries.clear();
2295
+ }
2296
+ /**
2297
+ * 动态添加一个新 job 并立即纳入调度
2298
+ */
2299
+ add(job) {
2300
+ const existing = this.entries.get(job.id);
2301
+ if (existing?.timerId !== void 0) {
2302
+ clearTimeout(existing.timerId);
2303
+ }
2304
+ this.scheduleJob(job);
2305
+ }
2306
+ /**
2307
+ * 按 id 移除 job 并取消其挂起的 timer
2308
+ * id 不存在时静默忽略,不抛出异常
2309
+ */
2310
+ remove(id) {
2311
+ const entry = this.entries.get(id);
2312
+ if (entry === void 0) return;
2313
+ if (entry.timerId !== void 0) {
2314
+ clearTimeout(entry.timerId);
2315
+ }
2316
+ this.entries.delete(id);
2317
+ }
2318
+ /**
2319
+ * 获取指定 job 的下次运行时间(ISO 字符串)
2320
+ * job 不存在时返回 undefined
2321
+ */
2322
+ getNextRunAt(jobId) {
2323
+ const entry = this.entries.get(jobId);
2324
+ if (entry === void 0) return void 0;
2325
+ return new Date(entry.nextRunAtMs).toISOString();
2326
+ }
2327
+ // ──────────────────────────────────────────────────────────────
2328
+ // 私有方法
2329
+ // ──────────────────────────────────────────────────────────────
2330
+ /**
2331
+ * 将 job 纳入调度:
2332
+ * 1. 计算第一次触发的延迟
2333
+ * 2. 若 nextRunAt 已过期,快速推进到下一个未来时间(不补跑)
2334
+ * 3. 挂起 setTimeout
2335
+ */
2336
+ scheduleJob(job) {
2337
+ const now = Date.now();
2338
+ let nextRunAtMs = new Date(job.nextRunAt).getTime();
2339
+ if (nextRunAtMs <= now) {
2340
+ const { intervalMs } = job;
2341
+ nextRunAtMs += intervalMs;
2342
+ while (nextRunAtMs <= now) {
2343
+ nextRunAtMs += intervalMs;
2344
+ }
2345
+ }
2346
+ const entry = {
2347
+ job,
2348
+ nextRunAtMs,
2349
+ timerId: void 0
2350
+ };
2351
+ this.entries.set(job.id, entry);
2352
+ this.arm(entry);
2353
+ }
2354
+ /**
2355
+ * 为 entry 挂起一个 setTimeout,延迟到 entry.nextRunAtMs
2356
+ */
2357
+ arm(entry) {
2358
+ const delay = Math.max(0, entry.nextRunAtMs - Date.now());
2359
+ entry.timerId = setTimeout(() => {
2360
+ void this.fire(entry);
2361
+ }, delay);
2362
+ }
2363
+ /**
2364
+ * job 到期时的触发逻辑:
2365
+ * 1. 检查 TTL,若已过期则不执行、不重新调度
2366
+ * 2. 调用 onJobRun;失败时调用 onJobError
2367
+ * 3. 无论成功失败,均重新调度下一次(TTL 由下次触发时再判断)
2368
+ */
2369
+ async fire(entry) {
2370
+ const { job } = entry;
2371
+ const now = Date.now();
2372
+ if (job.ttlMs !== void 0) {
2373
+ const expiresAt = new Date(job.createdAt).getTime() + job.ttlMs;
2374
+ if (now >= expiresAt) {
2375
+ this.entries.delete(job.id);
2376
+ return;
2377
+ }
2378
+ }
2379
+ try {
2380
+ await this.options.onJobRun(job);
2381
+ } catch (error) {
2382
+ this.options.onJobError(job, error);
2383
+ }
2384
+ const current = this.entries.get(job.id);
2385
+ if (current === void 0) return;
2386
+ current.nextRunAtMs = Date.now() + job.intervalMs;
2387
+ this.arm(current);
2388
+ }
2389
+ };
2390
+
2391
+ // src/automation/loop/command.ts
2392
+ import { randomUUID as randomUUID2 } from "crypto";
2393
+
2394
+ // src/automation/loop/parse.ts
2395
+ var DEFAULT_INTERVAL_MS = 6e5;
2396
+ var MIN_INTERVAL_MS = 3e4;
2397
+ var UNIT_MS = {
2398
+ s: 1e3,
2399
+ m: 6e4,
2400
+ h: 36e5
2401
+ };
2402
+ var TIME_TOKEN_RE = /(\d+)\s*([smh])/gi;
2403
+ function parseInterval(input) {
2404
+ if (input == null) return { intervalMs: DEFAULT_INTERVAL_MS };
2405
+ const trimmed = input.trim();
2406
+ if (trimmed === "") return { intervalMs: DEFAULT_INTERVAL_MS };
2407
+ if (trimmed.startsWith("-")) return null;
2408
+ const normalized = trimmed.replace(/^every\s+/i, "");
2409
+ if (/^\d+$/.test(normalized)) {
2410
+ const ms = parseInt(normalized, 10) * 1e3;
2411
+ return ms > 0 && ms >= MIN_INTERVAL_MS ? { intervalMs: ms } : null;
2412
+ }
2413
+ let totalMs = 0;
2414
+ let matchCount = 0;
2415
+ TIME_TOKEN_RE.lastIndex = 0;
2416
+ let match;
2417
+ while ((match = TIME_TOKEN_RE.exec(normalized)) !== null) {
2418
+ const value = parseInt(match[1], 10);
2419
+ const unit = match[2].toLowerCase();
2420
+ totalMs += value * UNIT_MS[unit];
2421
+ matchCount++;
2422
+ }
2423
+ if (matchCount === 0) return null;
2424
+ if (totalMs <= 0 || totalMs < MIN_INTERVAL_MS) return null;
2425
+ return { intervalMs: totalMs };
2426
+ }
2427
+
2428
+ // src/automation/loop/command.ts
2429
+ function formatInterval(ms) {
2430
+ const totalSec = Math.floor(ms / 1e3);
2431
+ const h = Math.floor(totalSec / 3600);
2432
+ const m = Math.floor(totalSec % 3600 / 60);
2433
+ const s = totalSec % 60;
2434
+ if (h > 0 && m === 0 && s === 0) return `${h}h`;
2435
+ if (h > 0 && s === 0) return `${h}h${m}m`;
2436
+ if (h > 0) return `${h}h${m}m${s}s`;
2437
+ if (m > 0 && s === 0) return `${m}m`;
2438
+ if (m > 0) return `${m}m${s}s`;
2439
+ return `${s}s`;
2440
+ }
2441
+ async function cmdLoop(args, deps) {
2442
+ const trimmed = args.trim();
2443
+ let intervalMs = DEFAULT_INTERVAL_MS;
2444
+ let prompt = trimmed;
2445
+ const spaceIdx = trimmed.indexOf(" ");
2446
+ if (spaceIdx !== -1) {
2447
+ const firstToken = trimmed.slice(0, spaceIdx);
2448
+ const parsed = parseInterval(firstToken);
2449
+ if (parsed !== null) {
2450
+ intervalMs = parsed.intervalMs;
2451
+ prompt = trimmed.slice(spaceIdx + 1).trim();
2452
+ }
2453
+ }
2454
+ const now = /* @__PURE__ */ new Date();
2455
+ const job = {
2456
+ id: randomUUID2(),
2457
+ kind: "loop",
2458
+ title: prompt.slice(0, 60),
2459
+ createdAt: now.toISOString(),
2460
+ updatedAt: now.toISOString(),
2461
+ state: "scheduled",
2462
+ promptTemplate: prompt,
2463
+ intervalMs,
2464
+ nextRunAt: new Date(now.getTime() + intervalMs).toISOString(),
2465
+ autoRunNow: false,
2466
+ runCount: 0,
2467
+ failureCount: 0
2468
+ };
2469
+ await upsertJob(deps.dataDir, job);
2470
+ deps.onSchedule(job);
2471
+ return `\u5DF2\u521B\u5EFA loop job [${job.id.slice(0, 8)}]\uFF0C\u6BCF ${formatInterval(intervalMs)} \u6267\u884C\u4E00\u6B21\uFF1A${prompt}`;
2472
+ }
2473
+ async function cmdJobs(deps) {
2474
+ const jobs = await loadJobs(deps.dataDir);
2475
+ if (jobs.length === 0) {
2476
+ return "\u6682\u65E0 automation job\u3002\u4F7F\u7528 /loop <interval> <prompt> \u521B\u5EFA\u4E00\u4E2A\u3002";
2477
+ }
2478
+ const lines = jobs.map((j) => {
2479
+ const interval = j.kind === "loop" ? formatInterval(j.intervalMs) : "\u2014";
2480
+ const idPrefix = j.id.slice(0, 8);
2481
+ return `[${idPrefix}] ${interval} ${j.promptTemplate} (${j.state})`;
2482
+ });
2483
+ return `\u5F53\u524D job \u5217\u8868\uFF08\u5171 ${jobs.length} \u4E2A\uFF09\uFF1A
2484
+ ${lines.join("\n")}`;
2485
+ }
2486
+ async function cmdUnloop(idOrPrefix, deps) {
2487
+ const jobs = await loadJobs(deps.dataDir);
2488
+ const target = jobs.find(
2489
+ (j) => j.id === idOrPrefix || j.id.startsWith(idOrPrefix)
2490
+ );
2491
+ if (target === void 0) {
2492
+ return `\u627E\u4E0D\u5230 job "${idOrPrefix}"\uFF0C\u8BF7\u7528 /jobs \u786E\u8BA4 id\u3002`;
2493
+ }
2494
+ await removeJob(deps.dataDir, target.id);
2495
+ deps.onUnschedule(target.id);
2496
+ return `\u5DF2\u53D6\u6D88 loop job [${target.id.slice(0, 8)}]\uFF1A${target.promptTemplate}`;
2497
+ }
2498
+
2499
+ // src/automation/goal/command.ts
2500
+ import { randomUUID as randomUUID3 } from "crypto";
2501
+ async function cmdGoal(args, deps) {
2502
+ const condition = args.trim();
2503
+ const now = /* @__PURE__ */ new Date();
2504
+ const job = {
2505
+ id: randomUUID3(),
2506
+ kind: "goal",
2507
+ title: condition.slice(0, 60),
2508
+ createdAt: now.toISOString(),
2509
+ updatedAt: now.toISOString(),
2510
+ state: "scheduled",
2511
+ promptTemplate: condition,
2512
+ condition,
2513
+ activeTurnCount: 0,
2514
+ runCount: 0,
2515
+ failureCount: 0
2516
+ };
2517
+ await upsertJob(deps.dataDir, job);
2518
+ deps.onSchedule(job);
2519
+ return `\u5DF2\u521B\u5EFA goal job [${job.id.slice(0, 8)}]\uFF0C\u76EE\u6807\u6761\u4EF6\uFF1A${condition}`;
2520
+ }
2521
+ async function cmdUngoal(idOrPrefix, deps) {
2522
+ const jobs = await loadJobs(deps.dataDir);
2523
+ const target = jobs.find(
2524
+ (j) => j.id === idOrPrefix || j.id.startsWith(idOrPrefix)
2525
+ );
2526
+ if (target === void 0) {
2527
+ return `\u627E\u4E0D\u5230 job "${idOrPrefix}"\uFF0C\u8BF7\u7528 /jobs \u786E\u8BA4 id\u3002`;
2528
+ }
2529
+ await removeJob(deps.dataDir, target.id);
2530
+ deps.onUnschedule(target.id);
2531
+ return `\u5DF2\u53D6\u6D88 goal job [${target.id.slice(0, 8)}]\uFF1A${target.promptTemplate}`;
2532
+ }
2533
+
2534
+ // src/automation/index.ts
2535
+ var AutomationManager = class {
2536
+ constructor(config2) {
2537
+ this.config = config2;
2538
+ this.scheduler = new LoopScheduler({
2539
+ onJobRun: async (job) => {
2540
+ const entry = await executeJob(job, {
2541
+ dataDir: config2.dataDir,
2542
+ logDir: config2.logDir,
2543
+ session: this.createSession()
2544
+ });
2545
+ if (entry) {
2546
+ const status = entry.result === "success" ? "\u2713" : "\u2717";
2547
+ const summary = entry.summaryText ?? entry.error ?? "";
2548
+ config2.onJobResult?.(
2549
+ job.id,
2550
+ `[loop ${job.id.slice(0, 8)}] ${status} ${summary}`.trim()
2551
+ );
2552
+ }
2553
+ },
2554
+ onJobError: (job, error) => {
2555
+ const msg = error instanceof Error ? error.message : String(error);
2556
+ config2.onJobResult?.(job.id, `[loop ${job.id.slice(0, 8)}] \u6267\u884C\u9519\u8BEF\uFF1A${msg}`);
2557
+ }
2558
+ });
2559
+ }
2560
+ config;
2561
+ scheduler;
2562
+ /** 按 jobId 索引的 goal runner AbortController */
2563
+ goalRunners = /* @__PURE__ */ new Map();
2564
+ /**
2565
+ * 从持久化存储加载所有 job,启动调度器并恢复 goal runner。
2566
+ * 应在应用启动时调用一次。
2567
+ */
2568
+ async start() {
2569
+ const jobs = await loadJobs(this.config.dataDir);
2570
+ const loopJobs = jobs.filter((j) => j.kind === "loop");
2571
+ const goalJobs = jobs.filter(
2572
+ (j) => j.kind === "goal" && j.state === "scheduled"
2573
+ );
2574
+ this.scheduler.start(loopJobs);
2575
+ for (const job of goalJobs) {
2576
+ this.startGoalRunner(job);
2577
+ }
2578
+ }
2579
+ /**
2580
+ * 停止所有调度:清除 loop timer,中止所有 goal runner。
2581
+ * 应在应用退出时调用。
2582
+ */
2583
+ stop() {
2584
+ this.scheduler.stop();
2585
+ for (const [, controller] of this.goalRunners) {
2586
+ controller.abort();
2587
+ }
2588
+ this.goalRunners.clear();
2589
+ }
2590
+ // ──────────────────────────────────────────────────────────────
2591
+ // 命令处理器(转发到 loop/goal 子模块)
2592
+ // ──────────────────────────────────────────────────────────────
2593
+ async cmdLoop(args) {
2594
+ return cmdLoop(args, {
2595
+ dataDir: this.config.dataDir,
2596
+ onSchedule: (job) => this.scheduler.add(job),
2597
+ onUnschedule: (id) => this.scheduler.remove(id)
2598
+ });
2599
+ }
2600
+ async cmdGoal(args) {
2601
+ return cmdGoal(args, {
2602
+ dataDir: this.config.dataDir,
2603
+ onSchedule: (job) => this.startGoalRunner(job),
2604
+ onUnschedule: (id) => this.stopGoalRunner(id)
2605
+ });
2606
+ }
2607
+ async cmdJobs() {
2608
+ return cmdJobs({
2609
+ dataDir: this.config.dataDir,
2610
+ onSchedule: () => {
2611
+ },
2612
+ onUnschedule: () => {
2613
+ }
2614
+ });
2615
+ }
2616
+ async cmdUnloop(id) {
2617
+ return cmdUnloop(id, {
2618
+ dataDir: this.config.dataDir,
2619
+ onSchedule: () => {
2620
+ },
2621
+ onUnschedule: (jobId) => this.scheduler.remove(jobId)
2622
+ });
2623
+ }
2624
+ async cmdUngoal(id) {
2625
+ return cmdUngoal(id, {
2626
+ dataDir: this.config.dataDir,
2627
+ onSchedule: () => {
2628
+ },
2629
+ onUnschedule: (jobId) => this.stopGoalRunner(jobId)
2630
+ });
2631
+ }
2632
+ // ──────────────────────────────────────────────────────────────
2633
+ // 私有辅助方法
2634
+ // ──────────────────────────────────────────────────────────────
2635
+ /**
2636
+ * 从 ProviderClient 构建 RuntimeSessionHandle。
2637
+ * 将流式 chunk 拼接为完整字符串返回给调用方。
2638
+ */
2639
+ createSession() {
2640
+ const { llmClient } = this.config;
2641
+ return {
2642
+ run: async (prompt) => {
2643
+ let text = "";
2644
+ const msgs = [{ role: "user", content: prompt }];
2645
+ for await (const chunk of llmClient.stream(msgs, [], void 0)) {
2646
+ if (chunk.text) text += chunk.text;
2647
+ }
2648
+ return text;
2649
+ }
2650
+ };
2651
+ }
2652
+ /** 启动 goal job 的执行协程(可被 AbortController 中断) */
2653
+ startGoalRunner(job) {
2654
+ this.stopGoalRunner(job.id);
2655
+ const ac = new AbortController();
2656
+ this.goalRunners.set(job.id, ac);
2657
+ void this.runGoalLoop(job, ac.signal);
2658
+ }
2659
+ /** 中止指定 goal runner */
2660
+ stopGoalRunner(jobId) {
2661
+ const ac = this.goalRunners.get(jobId);
2662
+ if (ac) {
2663
+ ac.abort();
2664
+ this.goalRunners.delete(jobId);
2665
+ }
2666
+ }
2667
+ /**
2668
+ * Goal job 执行循环:
2669
+ * 1. 调用 executeJob(LLM worker 执行一轮)
2670
+ * 2. 用 LLM 评估器判断条件是否满足
2671
+ * 3. done → 标记 completed;blocked → 标记 error;not_done → 等待 5s 后继续
2672
+ * 4. AbortSignal 触发时立即退出
2673
+ */
2674
+ async runGoalLoop(initialJob, signal) {
2675
+ let job = initialJob;
2676
+ while (!signal.aborted) {
2677
+ const runtimeConfig = {
2678
+ dataDir: this.config.dataDir,
2679
+ logDir: this.config.logDir,
2680
+ session: this.createSession()
2681
+ };
2682
+ const entry = await executeJob(job, runtimeConfig);
2683
+ if (!entry) break;
2684
+ if (signal.aborted) break;
2685
+ const evaluator = createLLMEvaluator(this.createSession());
2686
+ const evaluation = await evaluator.evaluate({
2687
+ condition: job.condition,
2688
+ transcript: entry.summaryText ?? entry.error ?? ""
2689
+ });
2690
+ const updatedJob = {
2691
+ ...job,
2692
+ lastEvaluation: evaluation,
2693
+ activeTurnCount: job.activeTurnCount + 1,
2694
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2695
+ };
2696
+ if (evaluation.verdict === "done") {
2697
+ const completedJob = {
2698
+ ...updatedJob,
2699
+ state: "completed",
2700
+ achievedAt: (/* @__PURE__ */ new Date()).toISOString()
2701
+ };
2702
+ await upsertJob(this.config.dataDir, completedJob);
2703
+ this.config.onJobResult?.(
2704
+ job.id,
2705
+ `[goal ${job.id.slice(0, 8)}] \u76EE\u6807\u5DF2\u8FBE\u6210\uFF1A${evaluation.reason}`
2706
+ );
2707
+ break;
2708
+ }
2709
+ if (evaluation.verdict === "blocked") {
2710
+ const errorJob = { ...updatedJob, state: "error" };
2711
+ await upsertJob(this.config.dataDir, errorJob);
2712
+ this.config.onJobResult?.(
2713
+ job.id,
2714
+ `[goal ${job.id.slice(0, 8)}] \u6267\u884C\u53D7\u963B\uFF1A${evaluation.reason}`
2715
+ );
2716
+ break;
2717
+ }
2718
+ await upsertJob(this.config.dataDir, updatedJob);
2719
+ job = updatedJob;
2720
+ this.config.onJobResult?.(
2721
+ job.id,
2722
+ `[goal ${job.id.slice(0, 8)}] \u7B2C ${job.activeTurnCount} \u8F6E\uFF1A\u5C1A\u672A\u5B8C\u6210\uFF0C\u7EE7\u7EED\u6267\u884C\u2026`
2723
+ );
2724
+ await new Promise((resolve5) => {
2725
+ const timer = setTimeout(resolve5, 5e3);
2726
+ signal.addEventListener("abort", () => {
2727
+ clearTimeout(timer);
2728
+ resolve5();
2729
+ }, { once: true });
2730
+ });
2731
+ }
2732
+ this.goalRunners.delete(job.id);
2733
+ }
2734
+ };
2735
+
2122
2736
  // src/ui/App.tsx
2123
2737
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
2738
+ async function handleAutomationCommand(input, manager) {
2739
+ if (!input.startsWith("/")) return null;
2740
+ const spaceIdx = input.indexOf(" ");
2741
+ const command = spaceIdx === -1 ? input : input.slice(0, spaceIdx);
2742
+ const args = spaceIdx === -1 ? "" : input.slice(spaceIdx + 1);
2743
+ switch (command) {
2744
+ case "/loop":
2745
+ return manager.cmdLoop(args);
2746
+ case "/goal":
2747
+ return manager.cmdGoal(args);
2748
+ case "/jobs":
2749
+ return manager.cmdJobs();
2750
+ case "/unloop":
2751
+ return manager.cmdUnloop(args.trim());
2752
+ case "/ungoal":
2753
+ return manager.cmdUngoal(args.trim());
2754
+ default:
2755
+ return null;
2756
+ }
2757
+ }
2124
2758
  function App({ config: config2, version: version2, autoMode: autoMode2 = false, registry: registry2, trustedSkillDirs: trustedSkillDirs2 = [], initialMessages: initialMessages2 = [], llmClient }) {
2125
2759
  const { stdout } = useStdout();
2126
2760
  const { stdin } = useStdin();
@@ -2154,6 +2788,17 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
2154
2788
  const pendingConfirmRef = useRef2(null);
2155
2789
  const abortControllerRef = useRef2(null);
2156
2790
  const llmRef = useRef2(llmClient ?? createProvider(resolveActiveProfile(config2)));
2791
+ const automationDataDir = config2.logDir ? join9(config2.logDir, "automation") : join9(homedir2(), ".config", "ecode", "automation");
2792
+ const automationManagerRef = useRef2(
2793
+ new AutomationManager({
2794
+ dataDir: automationDataDir,
2795
+ logDir: automationDataDir,
2796
+ llmClient: llmRef.current,
2797
+ onJobResult: (_jobId, message) => {
2798
+ setMessages((prev) => [...prev, { role: "assistant", content: message }]);
2799
+ }
2800
+ })
2801
+ );
2157
2802
  const inputRef = useRef2(null);
2158
2803
  const [skillTools, setSkillTools] = useState3([]);
2159
2804
  const skillToolsRef = useRef2([]);
@@ -2185,6 +2830,12 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
2185
2830
  }
2186
2831
  loggedCountRef.current = messages.length;
2187
2832
  }, [messages]);
2833
+ useEffect3(() => {
2834
+ void automationManagerRef.current.start();
2835
+ return () => {
2836
+ automationManagerRef.current.stop();
2837
+ };
2838
+ }, []);
2188
2839
  useEffect3(() => {
2189
2840
  if (!stdin || !stdout) return;
2190
2841
  stdout.write("\x1B[?1000h\x1B[?1006h");
@@ -2193,12 +2844,14 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
2193
2844
  if (event === "readable") {
2194
2845
  const chunk = stdin.read();
2195
2846
  if (chunk !== null) {
2196
- const mouseEvent = parseMouseScroll(chunk);
2197
- if (mouseEvent) {
2198
- if (mouseEvent.direction === "up") {
2199
- setScrollOffset((prev) => Math.min(prev + 3, Math.max(0, totalLinesRef.current - 1)));
2200
- } else {
2201
- setScrollOffset((prev) => Math.max(0, prev - 3));
2847
+ if (isMouseEvent(chunk)) {
2848
+ const scrollEvent = parseMouseScroll(chunk);
2849
+ if (scrollEvent) {
2850
+ if (scrollEvent.direction === "up") {
2851
+ setScrollOffset((prev) => Math.min(prev + 3, Math.max(0, totalLinesRef.current - 1)));
2852
+ } else {
2853
+ setScrollOffset((prev) => Math.max(0, prev - 3));
2854
+ }
2202
2855
  }
2203
2856
  return false;
2204
2857
  }
@@ -2530,6 +3183,20 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
2530
3183
  setInputHistory((prev) => [trimmed, ...prev.slice(0, 99)]);
2531
3184
  historyIndexRef.current = -1;
2532
3185
  setScrollOffset(0);
3186
+ {
3187
+ const automationResult = await handleAutomationCommand(
3188
+ trimmed,
3189
+ automationManagerRef.current
3190
+ );
3191
+ if (automationResult !== null) {
3192
+ setMessages((prev) => [
3193
+ ...prev,
3194
+ { role: "user", content: trimmed },
3195
+ { role: "assistant", content: automationResult }
3196
+ ]);
3197
+ return;
3198
+ }
3199
+ }
2533
3200
  if (registry2) {
2534
3201
  const skillResult = handleSkillInput(trimmed, registry2);
2535
3202
  if (skillResult.type === "error") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhongqian97-code/ecode",
3
- "version": "0.3.11",
3
+ "version": "0.4.0",
4
4
  "description": "A minimal Claude Code clone with REPL interface and bash tool calling",
5
5
  "type": "module",
6
6
  "author": "zhongqian97-code",