@zhongqian97-code/ecode 0.5.15 → 0.5.17

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 (2) hide show
  1. package/dist/index.js +1410 -393
  2. package/package.json +5 -1
package/dist/index.js CHANGED
@@ -7,10 +7,10 @@ import { resolve as resolve5, dirname as dirname9 } from "path";
7
7
  import { fileURLToPath as fileURLToPath2 } from "url";
8
8
  import React4 from "react";
9
9
  import { render } from "ink";
10
- import { readFileSync as readFileSync5 } from "fs";
10
+ import { readFileSync as readFileSync6 } from "fs";
11
11
 
12
12
  // src/config.ts
13
- import { existsSync, readFileSync } from "fs";
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
14
14
  import { homedir } from "os";
15
15
  import { join } from "path";
16
16
  var MODEL_CONTEXT_LIMITS = {
@@ -642,13 +642,13 @@ var READ_TOOL = {
642
642
  }
643
643
  };
644
644
  async function readFile2(params) {
645
- const { path: path11, offset = 0, limit } = params;
645
+ const { path: path12, offset = 0, limit } = params;
646
646
  let raw;
647
647
  try {
648
- raw = await fs.readFile(path11, "utf8");
648
+ raw = await fs.readFile(path12, "utf8");
649
649
  } catch (err) {
650
650
  const msg = err instanceof Error ? err.message : String(err);
651
- return `Error reading ${path11}: ${msg}`;
651
+ return `Error reading ${path12}: ${msg}`;
652
652
  }
653
653
  const lines = raw.split("\n");
654
654
  const sliced = limit !== void 0 ? lines.slice(offset, offset + limit) : lines.slice(offset);
@@ -704,28 +704,28 @@ var EDIT_TOOL = {
704
704
  }
705
705
  };
706
706
  async function editFile(params) {
707
- const { path: path11, old_string, new_string } = params;
707
+ const { path: path12, old_string, new_string } = params;
708
708
  let content;
709
709
  try {
710
- content = await fs3.readFile(path11, "utf8");
710
+ content = await fs3.readFile(path12, "utf8");
711
711
  } catch (err) {
712
712
  const msg = err instanceof Error ? err.message : String(err);
713
- return `Error reading ${path11}: ${msg}`;
713
+ return `Error reading ${path12}: ${msg}`;
714
714
  }
715
715
  const count = countOccurrences(content, old_string);
716
716
  if (count === 0) {
717
- return `Error: old_string not found in ${path11}`;
717
+ return `Error: old_string not found in ${path12}`;
718
718
  }
719
719
  if (count > 1) {
720
- return `Error: old_string appears ${count} times in ${path11} (ambiguous \u2014 add more context)`;
720
+ return `Error: old_string appears ${count} times in ${path12} (ambiguous \u2014 add more context)`;
721
721
  }
722
722
  const updated = content.replace(old_string, new_string);
723
723
  try {
724
- await fs3.writeFile(path11, updated, "utf8");
725
- return `Edited ${path11}`;
724
+ await fs3.writeFile(path12, updated, "utf8");
725
+ return `Edited ${path12}`;
726
726
  } catch (err) {
727
727
  const msg = err instanceof Error ? err.message : String(err);
728
- return `Error writing ${path11}: ${msg}`;
728
+ return `Error writing ${path12}: ${msg}`;
729
729
  }
730
730
  }
731
731
  function countOccurrences(haystack, needle) {
@@ -1276,19 +1276,96 @@ function todo(params) {
1276
1276
  }
1277
1277
  }
1278
1278
 
1279
- // src/sessions/metadata.ts
1280
- import * as crypto from "crypto";
1279
+ // src/tools/task.ts
1280
+ import * as crypto3 from "crypto";
1281
+ import * as path7 from "path";
1282
+
1283
+ // src/subagent/runtime.ts
1284
+ async function runSubagentRuntime(options) {
1285
+ const { messages: initial, tools, provider, maxTurns, executeToolCall: executeToolCall2, onOutput } = options;
1286
+ const messages = [...initial];
1287
+ let turnCount = 0;
1288
+ while (turnCount < maxTurns) {
1289
+ turnCount++;
1290
+ let assistantText = "";
1291
+ const toolCalls = [];
1292
+ for await (const chunk of provider.stream(messages, tools)) {
1293
+ if (chunk.text) {
1294
+ assistantText += chunk.text;
1295
+ onOutput?.(chunk.text);
1296
+ }
1297
+ if (chunk.done && chunk.toolCalls) {
1298
+ toolCalls.push(...chunk.toolCalls);
1299
+ }
1300
+ }
1301
+ if (toolCalls.length === 0) {
1302
+ messages.push({ role: "assistant", content: assistantText });
1303
+ return { success: true, finalText: assistantText, turnCount };
1304
+ }
1305
+ messages.push({
1306
+ role: "assistant",
1307
+ content: assistantText || null,
1308
+ tool_calls: toolCalls.map((tc) => ({
1309
+ id: tc.id,
1310
+ type: "function",
1311
+ function: { name: tc.name, arguments: tc.arguments }
1312
+ }))
1313
+ });
1314
+ for (const tc of toolCalls) {
1315
+ const result = await executeToolCall2(tc);
1316
+ messages.push({ role: "tool", tool_call_id: tc.id, content: result });
1317
+ }
1318
+ }
1319
+ return {
1320
+ success: false,
1321
+ finalText: "",
1322
+ turnCount,
1323
+ error: `Subagent exceeded max turns (${maxTurns})`
1324
+ };
1325
+ }
1326
+
1327
+ // src/logger.ts
1281
1328
  import * as fs7 from "fs";
1282
1329
  import * as path5 from "path";
1330
+ function createLogger(logDir, sessionStart) {
1331
+ fs7.mkdirSync(logDir, { recursive: true });
1332
+ const filename = sessionStart.toISOString().replace(/:/g, "-").replace(/\..+/, "") + ".jsonl";
1333
+ const filePath = path5.join(logDir, filename);
1334
+ return {
1335
+ filePath,
1336
+ /**
1337
+ * 将单条日志条目序列化为 JSON 并同步追加到文件(末尾加换行符)。
1338
+ *
1339
+ * 使用 appendFileSync 而非 appendFile(异步版本)的原因:
1340
+ * 进程崩溃或 Ctrl-C 退出时,异步写操作可能尚未完成,导致最后几条记录丢失。
1341
+ * 同步写入虽然阻塞事件循环,但日志条目通常很小(< 1KB),延迟可忽略。
1342
+ *
1343
+ * 写入失败时输出到 stderr 而非抛出异常,防止日志错误中断正常业务流程。
1344
+ */
1345
+ append(entry) {
1346
+ try {
1347
+ fs7.appendFileSync(filePath, JSON.stringify(entry) + "\n");
1348
+ } catch (err) {
1349
+ process.stderr.write(`[logger] Failed to write log entry: ${err}
1350
+ `);
1351
+ }
1352
+ }
1353
+ };
1354
+ }
1355
+
1356
+ // src/sessions/metadata.ts
1357
+ import * as crypto2 from "crypto";
1358
+ import * as fs8 from "fs";
1359
+ import * as path6 from "path";
1283
1360
  function metadataPathFromLogFile(logFilePath2) {
1284
- const base = path5.basename(logFilePath2, ".jsonl");
1285
- const dir = path5.dirname(logFilePath2);
1286
- return path5.join(dir, `${base}-session.json`);
1361
+ const base = path6.basename(logFilePath2, ".jsonl");
1362
+ const dir = path6.dirname(logFilePath2);
1363
+ return path6.join(dir, `${base}-session.json`);
1287
1364
  }
1288
1365
  function createSessionMetadata(logFilePath2, model) {
1289
1366
  const now = (/* @__PURE__ */ new Date()).toISOString();
1290
1367
  return {
1291
- id: crypto.randomUUID(),
1368
+ id: crypto2.randomUUID(),
1292
1369
  startTime: now,
1293
1370
  lastActivity: now,
1294
1371
  cwd: process.cwd(),
@@ -1296,13 +1373,13 @@ function createSessionMetadata(logFilePath2, model) {
1296
1373
  title: "",
1297
1374
  turnCount: 0,
1298
1375
  totalTokens: 0,
1299
- logFile: path5.basename(logFilePath2)
1376
+ logFile: path6.basename(logFilePath2)
1300
1377
  };
1301
1378
  }
1302
1379
  function writeSessionMetadata(logFilePath2, metadata) {
1303
1380
  const metaPath = metadataPathFromLogFile(logFilePath2);
1304
1381
  try {
1305
- fs7.writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + "\n");
1382
+ fs8.writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + "\n");
1306
1383
  } catch (err) {
1307
1384
  process.stderr.write(`[sessions] Failed to write metadata: ${err}
1308
1385
  `);
@@ -1310,7 +1387,7 @@ function writeSessionMetadata(logFilePath2, metadata) {
1310
1387
  }
1311
1388
  function readSessionMetadata(metaFilePath) {
1312
1389
  try {
1313
- const raw = fs7.readFileSync(metaFilePath, "utf-8");
1390
+ const raw = fs8.readFileSync(metaFilePath, "utf-8");
1314
1391
  return JSON.parse(raw);
1315
1392
  } catch {
1316
1393
  return null;
@@ -1320,7 +1397,7 @@ function updateSessionMetadata(logFilePath2, partial) {
1320
1397
  const metaPath = metadataPathFromLogFile(logFilePath2);
1321
1398
  let existing = null;
1322
1399
  try {
1323
- const raw = fs7.readFileSync(metaPath, "utf-8");
1400
+ const raw = fs8.readFileSync(metaPath, "utf-8");
1324
1401
  existing = JSON.parse(raw);
1325
1402
  } catch {
1326
1403
  return;
@@ -1329,11 +1406,11 @@ function updateSessionMetadata(logFilePath2, partial) {
1329
1406
  }
1330
1407
  function listSessions(logDir) {
1331
1408
  try {
1332
- const files = fs7.readdirSync(logDir);
1409
+ const files = fs8.readdirSync(logDir);
1333
1410
  const metaFiles = files.filter((f) => f.endsWith("-session.json"));
1334
1411
  const sessions = [];
1335
1412
  for (const file of metaFiles) {
1336
- const meta = readSessionMetadata(path5.join(logDir, file));
1413
+ const meta = readSessionMetadata(path6.join(logDir, file));
1337
1414
  if (meta) sessions.push(meta);
1338
1415
  }
1339
1416
  return sessions.sort(
@@ -1354,101 +1431,54 @@ function generateTitle(firstUserMessage) {
1354
1431
  return oneLine.length > 50 ? oneLine.slice(0, 47) + "..." : oneLine;
1355
1432
  }
1356
1433
 
1357
- // src/logger.ts
1358
- import * as fs8 from "fs";
1359
- import * as path6 from "path";
1360
- function createLogger(logDir, sessionStart) {
1361
- fs8.mkdirSync(logDir, { recursive: true });
1362
- const filename = sessionStart.toISOString().replace(/:/g, "-").replace(/\..+/, "") + ".jsonl";
1363
- const filePath = path6.join(logDir, filename);
1364
- return {
1365
- filePath,
1366
- /**
1367
- * 将单条日志条目序列化为 JSON 并同步追加到文件(末尾加换行符)。
1368
- *
1369
- * 使用 appendFileSync 而非 appendFile(异步版本)的原因:
1370
- * 进程崩溃或 Ctrl-C 退出时,异步写操作可能尚未完成,导致最后几条记录丢失。
1371
- * 同步写入虽然阻塞事件循环,但日志条目通常很小(< 1KB),延迟可忽略。
1372
- *
1373
- * 写入失败时输出到 stderr 而非抛出异常,防止日志错误中断正常业务流程。
1374
- */
1375
- append(entry) {
1376
- try {
1377
- fs8.appendFileSync(filePath, JSON.stringify(entry) + "\n");
1378
- } catch (err) {
1379
- process.stderr.write(`[logger] Failed to write log entry: ${err}
1380
- `);
1381
- }
1382
- }
1383
- };
1384
- }
1385
-
1386
1434
  // src/tools/task.ts
1387
1435
  var TASK_TOOL = {
1388
1436
  type: "function",
1389
1437
  function: {
1390
1438
  name: "task",
1391
- description: "\u628A\u4E00\u4E2A\u5B50\u4EFB\u52A1\u59D4\u6258\u7ED9\u9694\u79BB\u7684\u5B50 agent\u3002\u9002\u5408\u4EE3\u7801\u8C03\u7814\u3001\u5C40\u90E8\u5B9E\u73B0\u3001\u6587\u4EF6\u4FEE\u6539\u6216\u7ED3\u679C\u6C47\u603B\u3002\u8FD4\u56DE\u5355\u6761\u6700\u7EC8\u7ED3\u679C\u3002",
1439
+ description: "Delegate a well-defined subtask to an isolated child agent. The child agent runs its own agentic loop, executes tools, and returns a single result. Use this when a subtask is independent, well-scoped, and benefits from a clean context.",
1392
1440
  parameters: {
1393
1441
  type: "object",
1394
1442
  properties: {
1395
1443
  description: {
1396
1444
  type: "string",
1397
- description: "3-8 \u4E2A\u8BCD\u7684\u77ED\u63CF\u8FF0\uFF0C\u7528\u4E8E\u6807\u8BC6\u8FD9\u4E2A\u5B50\u4EFB\u52A1\u3002"
1445
+ description: "Short title for the subtask (used in logs and status output)."
1398
1446
  },
1399
1447
  prompt: {
1400
1448
  type: "string",
1401
- description: "\u4EA4\u7ED9\u5B50 agent \u7684\u5B8C\u6574\u4EFB\u52A1\u8BF4\u660E\u3002"
1449
+ description: "Full task instructions for the child agent."
1402
1450
  },
1403
1451
  context: {
1404
1452
  type: "string",
1405
- description: "\u53EF\u9009\u3002\u989D\u5916\u8865\u5145\u7ED9\u5B50 agent \u7684\u4E0A\u4E0B\u6587\u8BF4\u660E\u3002"
1453
+ description: "Optional additional context to pass to the child agent."
1406
1454
  },
1407
- model: {
1455
+ cwd: {
1408
1456
  type: "string",
1409
- description: "\u53EF\u9009\u3002\u8986\u76D6\u5B50 agent \u4F7F\u7528\u7684\u6A21\u578B\u3002"
1457
+ description: "Working directory for the child agent (defaults to current directory)."
1410
1458
  },
1411
- cwd: {
1459
+ model: {
1412
1460
  type: "string",
1413
- description: "\u53EF\u9009\u3002\u5B50 agent \u7684\u5DE5\u4F5C\u76EE\u5F55\uFF0C\u9ED8\u8BA4\u7EE7\u627F\u5F53\u524D process.cwd()\u3002"
1461
+ description: "Optional model override for the child agent."
1414
1462
  },
1415
1463
  max_turns: {
1416
1464
  type: "number",
1417
- description: "\u53EF\u9009\u3002\u5B50 agent \u6700\u591A\u53EF\u6267\u884C\u591A\u5C11\u8F6E\u5DE5\u5177\u5FAA\u73AF\uFF0C\u9ED8\u8BA4 8\u3002"
1465
+ description: "Maximum agentic loop turns for the child agent (default: 8)."
1418
1466
  }
1419
1467
  },
1420
1468
  required: ["description", "prompt"]
1421
1469
  }
1422
1470
  }
1423
1471
  };
1424
- var SUBAGENT_SYSTEM_PROMPT = [
1425
- DEFAULT_SYSTEM_PROMPT,
1426
- "",
1427
- "You are a focused subagent working for another ecode session.",
1428
- "Finish the delegated task independently when possible.",
1429
- "Use tools directly instead of asking the human for routine lookups.",
1430
- "Return a concise final result for the parent agent.",
1431
- "If blocked, clearly state the blocker and the smallest next step."
1432
- ].join("\n");
1433
- var BASH_TOOL = {
1434
- type: "function",
1435
- function: {
1436
- name: "bash",
1437
- description: "Execute a shell command and return its output.",
1438
- parameters: {
1439
- type: "object",
1440
- properties: {
1441
- command: { type: "string", description: "The shell command to run." }
1442
- },
1443
- required: ["command"]
1444
- }
1445
- }
1446
- };
1472
+ var SUBAGENT_SYSTEM_PROMPT = `You are a focused subagent executing a delegated task on behalf of a parent agent.
1473
+
1474
+ Rules:
1475
+ - Complete the task independently using the tools available to you.
1476
+ - Do not ask the human clarifying questions for routine operations \u2014 make reasonable decisions.
1477
+ - When done, return a clear, concise result.
1478
+ - If you are blocked, describe the blocker and the minimum next step needed.
1479
+ - You cannot delegate tasks to other agents.`;
1447
1480
  var SUBAGENT_TOOLS = [
1448
- {
1449
- type: BASH_TOOL.type,
1450
- function: BASH_TOOL.function
1451
- },
1481
+ BASH_TOOL,
1452
1482
  READ_TOOL,
1453
1483
  WRITE_TOOL,
1454
1484
  EDIT_TOOL,
@@ -1457,223 +1487,399 @@ var SUBAGENT_TOOLS = [
1457
1487
  APPLY_PATCH_TOOL,
1458
1488
  TODO_TOOL
1459
1489
  ];
1460
- function clip(text, maxChars) {
1461
- return text.length <= maxChars ? text : `${text.slice(0, maxChars - 3)}...`;
1462
- }
1463
- function messageToContextLine(msg) {
1464
- if (msg.role === "system") return null;
1465
- if (msg.role === "user") return `user: ${clip(msg.content, 500)}`;
1466
- if (msg.role === "tool") return `tool(${msg.tool_call_id}): ${clip(msg.content, 700)}`;
1467
- const parts = [];
1468
- if (msg.content) parts.push(clip(msg.content, 700));
1469
- if (msg.tool_calls?.length) {
1470
- parts.push(
1471
- `[tool_calls: ${msg.tool_calls.map((tc) => tc.function.name).join(", ")}]`
1472
- );
1490
+ async function handleTaskTool(args, deps) {
1491
+ const { description, prompt, context, max_turns = 8 } = args;
1492
+ const { provider, print = () => void 0, logDir } = deps;
1493
+ const taskId = crypto3.randomUUID().slice(0, 8);
1494
+ let logger;
1495
+ if (logDir) {
1496
+ logger = createLogger(logDir, /* @__PURE__ */ new Date());
1497
+ const meta = createSessionMetadata(logger.filePath, "subagent");
1498
+ writeSessionMetadata(logger.filePath, {
1499
+ ...meta,
1500
+ title: `[subagent] ${description}`
1501
+ });
1473
1502
  }
1474
- return `assistant: ${parts.join(" ")}`.trim();
1475
- }
1476
- function buildParentContext(parentMessages, extraContext) {
1477
- const lines = [];
1478
- if (extraContext?.trim()) {
1479
- lines.push("## Caller-provided context");
1480
- lines.push(extraContext.trim());
1503
+ const userContent = context ? `Context:
1504
+ ${context}
1505
+
1506
+ Task:
1507
+ ${prompt}` : prompt;
1508
+ const messages = [
1509
+ { role: "system", content: SUBAGENT_SYSTEM_PROMPT },
1510
+ { role: "user", content: userContent }
1511
+ ];
1512
+ if (logger) {
1513
+ logger.append({ ts: (/* @__PURE__ */ new Date()).toISOString(), role: "user", content: userContent });
1514
+ }
1515
+ const bashDeps = {
1516
+ executeBash,
1517
+ confirm: async () => false,
1518
+ // danger commands will be denied
1519
+ print,
1520
+ autoApproveNormal: true
1521
+ };
1522
+ const executeToolCall2 = async (tc) => {
1523
+ let result;
1524
+ if (tc.name === "bash") {
1525
+ const tcArgs = JSON.parse(tc.arguments);
1526
+ result = await handleBashTool(tcArgs.command, bashDeps);
1527
+ } else if (tc.name === "read") {
1528
+ const tcArgs = JSON.parse(tc.arguments);
1529
+ result = await readFile2(tcArgs);
1530
+ } else if (tc.name === "write") {
1531
+ const tcArgs = JSON.parse(tc.arguments);
1532
+ result = await writeFile2(tcArgs);
1533
+ } else if (tc.name === "edit") {
1534
+ const tcArgs = JSON.parse(tc.arguments);
1535
+ result = await editFile(tcArgs);
1536
+ } else if (tc.name === "glob") {
1537
+ const tcArgs = JSON.parse(tc.arguments);
1538
+ result = await globFiles(tcArgs);
1539
+ } else if (tc.name === "grep") {
1540
+ const tcArgs = JSON.parse(tc.arguments);
1541
+ result = await grepFiles(tcArgs);
1542
+ } else if (tc.name === "apply_patch") {
1543
+ const tcArgs = JSON.parse(tc.arguments);
1544
+ result = await applyPatch(tcArgs);
1545
+ } else if (tc.name === "todo") {
1546
+ const tcArgs = JSON.parse(tc.arguments);
1547
+ result = todo(tcArgs);
1548
+ } else {
1549
+ result = `Unknown tool: ${tc.name}`;
1550
+ }
1551
+ if (logger) {
1552
+ logger.append({
1553
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1554
+ role: "tool",
1555
+ content: result,
1556
+ tool_call_id: tc.id
1557
+ });
1558
+ }
1559
+ return result;
1560
+ };
1561
+ const runtime = await runSubagentRuntime({
1562
+ messages,
1563
+ tools: SUBAGENT_TOOLS,
1564
+ provider,
1565
+ maxTurns: max_turns,
1566
+ executeToolCall: executeToolCall2,
1567
+ onOutput: (text) => print(text)
1568
+ });
1569
+ if (logger) {
1570
+ logger.append({
1571
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1572
+ role: "assistant",
1573
+ content: runtime.finalText || runtime.error || null
1574
+ });
1575
+ updateSessionMetadata(logger.filePath, {
1576
+ lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
1577
+ turnCount: runtime.turnCount,
1578
+ title: `[subagent] ${description}`
1579
+ });
1481
1580
  }
1482
- const tail = (parentMessages ?? []).slice(-8).map(messageToContextLine).filter((line) => Boolean(line));
1483
- if (tail.length > 0) {
1484
- if (lines.length > 0) lines.push("");
1485
- lines.push("## Recent parent-session context");
1486
- lines.push(...tail);
1581
+ const logLine = logger ? `log_file: ${path7.basename(logger.filePath)}
1582
+ ` : "";
1583
+ if (!runtime.success) {
1584
+ return [
1585
+ `task_id: ${taskId}`,
1586
+ `description: ${description}`,
1587
+ logLine.trim(),
1588
+ "",
1589
+ `<task_result>`,
1590
+ `Error: ${runtime.error ?? "Subagent failed"}`,
1591
+ `</task_result>`
1592
+ ].filter((l) => l !== "" || l === "").join("\n").replace(/\n{3,}/g, "\n\n");
1487
1593
  }
1488
- if (lines.length === 0) return void 0;
1489
- return clip(lines.join("\n"), 4e3);
1490
- }
1491
- function appendLog(logger, entry) {
1492
- if (!logger) return;
1493
- logger.append({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
1494
- }
1495
- var SKIP_MESSAGE = "Command skipped by user.";
1496
- async function runNonInteractiveBash(command, autoApproveNormal) {
1497
- const cls = classifyCommand(command);
1498
- if (cls === "danger") return SKIP_MESSAGE;
1499
- if (cls === "normal" && !autoApproveNormal) return SKIP_MESSAGE;
1500
- const result = await executeBash(command);
1501
- let output = "";
1502
- if (result.stdout) output += result.stdout;
1503
- if (result.stderr) output += result.stderr;
1504
- if (result.exitCode !== 0) output += `
1505
- [exit code: ${result.exitCode}]`;
1506
- return output || "(no output)";
1594
+ return [
1595
+ `task_id: ${taskId}`,
1596
+ `description: ${description}`,
1597
+ logLine.trim(),
1598
+ "",
1599
+ `<task_result>`,
1600
+ runtime.finalText,
1601
+ `</task_result>`
1602
+ ].join("\n").replace(/\n{3,}/g, "\n\n");
1507
1603
  }
1508
- async function executeSubagentToolCall(name, args, autoApproveNormal) {
1509
- if (name === "bash") {
1510
- let parsed;
1511
- try {
1512
- parsed = JSON.parse(args);
1513
- } catch {
1514
- parsed = { command: "" };
1604
+
1605
+ // src/tools/web_fetch.ts
1606
+ var WEB_FETCH_TOOL = {
1607
+ type: "function",
1608
+ function: {
1609
+ name: "web_fetch",
1610
+ description: "Fetch a URL and extract its text content. Supports HTML, plain text and markdown pages. Returns a structured response with headers and extracted content.",
1611
+ parameters: {
1612
+ type: "object",
1613
+ properties: {
1614
+ url: { type: "string", description: "The URL to fetch (http or https only)." },
1615
+ extract_mode: {
1616
+ type: "string",
1617
+ enum: ["text", "markdown"],
1618
+ description: "How to extract content from HTML: 'markdown' (default) preserves headings/lists as markdown, 'text' strips all tags."
1619
+ },
1620
+ max_chars: {
1621
+ type: "number",
1622
+ description: "Maximum characters to return (default 20000, max 100000)."
1623
+ },
1624
+ timeout_ms: {
1625
+ type: "number",
1626
+ description: "Request timeout in milliseconds (default 20000, min 1000, max 60000)."
1627
+ }
1628
+ },
1629
+ required: ["url"]
1515
1630
  }
1516
- return runNonInteractiveBash(parsed.command, autoApproveNormal);
1517
- }
1518
- if (name === "read") {
1519
- return readFile2(JSON.parse(args));
1520
- }
1521
- if (name === "write") {
1522
- return writeFile2(JSON.parse(args));
1523
1631
  }
1524
- if (name === "edit") {
1525
- return editFile(JSON.parse(args));
1526
- }
1527
- if (name === "glob") {
1528
- return globFiles(JSON.parse(args));
1529
- }
1530
- if (name === "grep") {
1531
- return grepFiles(JSON.parse(args));
1532
- }
1533
- if (name === "apply_patch") {
1534
- return applyPatch(JSON.parse(args));
1535
- }
1536
- if (name === "todo") {
1537
- return todo(JSON.parse(args));
1632
+ };
1633
+ var CACHE_TTL = 6e5;
1634
+ var CACHE_MAX = 64;
1635
+ var cache = /* @__PURE__ */ new Map();
1636
+ function cacheGet(key) {
1637
+ const entry = cache.get(key);
1638
+ if (!entry) return void 0;
1639
+ if (Date.now() - entry.at > CACHE_TTL) {
1640
+ cache.delete(key);
1641
+ return void 0;
1642
+ }
1643
+ return entry.result;
1644
+ }
1645
+ function cacheSet(key, result) {
1646
+ if (cache.size >= CACHE_MAX) {
1647
+ const oldest = cache.keys().next().value;
1648
+ if (oldest !== void 0) cache.delete(oldest);
1649
+ }
1650
+ cache.set(key, { at: Date.now(), result });
1651
+ }
1652
+ function isPrivateHost(hostname) {
1653
+ if (hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1") return true;
1654
+ if (hostname.endsWith(".local")) return true;
1655
+ const v4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
1656
+ if (v4) {
1657
+ const [, a, b] = v4.map(Number);
1658
+ if (a === 127) return true;
1659
+ if (a === 10) return true;
1660
+ if (a === 192 && b === 168) return true;
1661
+ if (a === 172 && b >= 16 && b <= 31) return true;
1662
+ if (a === 0) return true;
1538
1663
  }
1539
- return `Unknown tool: ${name}`;
1664
+ return false;
1540
1665
  }
1541
- async function runTaskTool(params, deps) {
1542
- const maxTurns = Math.max(1, Math.min(32, params.max_turns ?? 8));
1543
- const contextBlock = buildParentContext(deps.parentMessages, params.context);
1544
- const originalCwd = process.cwd();
1545
- const childCwd = params.cwd ?? originalCwd;
1546
- const profile = resolveActiveProfile(deps.config);
1547
- const llm = deps.llm ?? createProvider({
1548
- ...profile,
1549
- model: params.model ?? profile.model
1550
- });
1551
- const messages = [{ role: "system", content: SUBAGENT_SYSTEM_PROMPT }];
1552
- if (contextBlock) {
1553
- messages.push({
1554
- role: "user",
1555
- content: "Parent session context for this delegated task:\n\n" + contextBlock
1556
- });
1557
- }
1558
- messages.push({ role: "user", content: params.prompt });
1559
- let logger = null;
1560
- let sessionMeta = null;
1561
- if (deps.config.logDir) {
1562
- logger = createLogger(deps.config.logDir, /* @__PURE__ */ new Date());
1563
- sessionMeta = createSessionMetadata(
1564
- logger.filePath,
1565
- params.model ?? profile.model
1566
- );
1567
- sessionMeta.title = generateTitle(`[subagent] ${params.description}`);
1568
- writeSessionMetadata(logger.filePath, sessionMeta);
1569
- }
1570
- for (const msg of messages) {
1571
- appendLog(logger, {
1572
- role: msg.role,
1573
- content: "content" in msg ? msg.content : null
1574
- });
1575
- }
1576
- const autoApproveNormal = deps.autoApproveNormal ?? true;
1666
+ function validateWebFetchUrl(rawUrl) {
1667
+ let parsed;
1577
1668
  try {
1578
- process.chdir(childCwd);
1579
- for (let turn = 0; turn < maxTurns; turn++) {
1580
- let assistantText = "";
1581
- let assistantReasoning;
1582
- const toolCalls = [];
1583
- for await (const chunk of llm.stream(messages, SUBAGENT_TOOLS)) {
1584
- if (chunk.text) assistantText += chunk.text;
1585
- if (chunk.done) {
1586
- if (chunk.toolCalls) toolCalls.push(...chunk.toolCalls);
1587
- if (chunk.reasoning) assistantReasoning = chunk.reasoning;
1588
- }
1589
- }
1590
- if (toolCalls.length === 0) {
1591
- if (assistantText) {
1592
- messages.push({
1593
- role: "assistant",
1594
- content: assistantText,
1595
- ...assistantReasoning ? { reasoning_content: assistantReasoning } : {}
1596
- });
1597
- appendLog(logger, { role: "assistant", content: assistantText });
1598
- }
1599
- if (logger) {
1600
- updateSessionMetadata(logger.filePath, {
1601
- lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
1602
- turnCount: turn + 1
1603
- });
1604
- }
1605
- const taskId = sessionMeta?.id ?? "ephemeral";
1606
- return [
1607
- `task_id: ${taskId}`,
1608
- `description: ${params.description}`,
1609
- `model: ${params.model ?? profile.model}`,
1610
- logger ? `log_file: ${logger.filePath}` : void 0,
1611
- "",
1612
- "<task_result>",
1613
- assistantText || "(no final text)",
1614
- "</task_result>"
1615
- ].filter((line) => line !== void 0).join("\n");
1669
+ parsed = new URL(rawUrl);
1670
+ } catch {
1671
+ return { ok: false, error: `invalid URL: ${rawUrl}` };
1672
+ }
1673
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1674
+ return { ok: false, error: `unsupported protocol: ${parsed.protocol}` };
1675
+ }
1676
+ if (parsed.username || parsed.password) {
1677
+ return { ok: false, error: "URL must not contain credentials" };
1678
+ }
1679
+ if (isPrivateHost(parsed.hostname)) {
1680
+ return { ok: false, error: `host is private/local: ${parsed.hostname}` };
1681
+ }
1682
+ return { ok: true, parsed };
1683
+ }
1684
+ function rootHost(hostname) {
1685
+ const parts = hostname.split(".");
1686
+ return parts.length > 2 ? parts.slice(-2).join(".") : hostname;
1687
+ }
1688
+ function isSameOrWwwHost(a, b) {
1689
+ if (a === b) return true;
1690
+ const ra = rootHost(a);
1691
+ const rb = rootHost(b);
1692
+ return ra === rb && (a === `www.${ra}` || b === `www.${ra}` || a === ra || b === ra);
1693
+ }
1694
+ function extractHtmlText(html, mode) {
1695
+ let s = html;
1696
+ s = s.replace(/<script[\s\S]*?<\/script>/gi, "");
1697
+ s = s.replace(/<style[\s\S]*?<\/style>/gi, "");
1698
+ s = s.replace(/<!--[\s\S]*?-->/g, "");
1699
+ if (mode === "markdown") {
1700
+ s = s.replace(/<pre[^>]*>\s*<code[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi, (_, code) => {
1701
+ const decoded = decodeEntities(code);
1702
+ return `
1703
+
1704
+ \`\`\`
1705
+ ${decoded}
1706
+ \`\`\`
1707
+
1708
+ `;
1709
+ });
1710
+ s = s.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_, code) => `\`${code}\``);
1711
+ for (let n = 1; n <= 6; n++) {
1712
+ const hashes = "#".repeat(n);
1713
+ s = s.replace(new RegExp(`<h${n}[^>]*>`, "gi"), `
1714
+
1715
+ ${hashes} `);
1716
+ s = s.replace(new RegExp(`</h${n}>`, "gi"), "\n\n");
1717
+ }
1718
+ s = s.replace(/<li[^>]*>/gi, "\n- ");
1719
+ } else {
1720
+ for (let n = 1; n <= 6; n++) {
1721
+ s = s.replace(new RegExp(`<h${n}[^>]*>`, "gi"), "\n\n");
1722
+ s = s.replace(new RegExp(`</h${n}>`, "gi"), "\n\n");
1723
+ }
1724
+ s = s.replace(/<li[^>]*>/gi, "\n");
1725
+ }
1726
+ s = s.replace(/<\/p>/gi, "\n\n");
1727
+ s = s.replace(/<p[^>]*>/gi, "\n\n");
1728
+ s = s.replace(/<\/div>/gi, "\n");
1729
+ s = s.replace(/<br\s*\/?>/gi, "\n");
1730
+ s = s.replace(/<\/li>/gi, "\n");
1731
+ s = s.replace(/<[^>]+>/g, "");
1732
+ s = decodeEntities(s);
1733
+ s = s.replace(/[ \t]+/g, " ");
1734
+ s = s.replace(/\n{3,}/g, "\n\n");
1735
+ return s.trim();
1736
+ }
1737
+ function decodeEntities(s) {
1738
+ return s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
1739
+ }
1740
+ function statusText(status) {
1741
+ const map = {
1742
+ 200: "OK",
1743
+ 201: "Created",
1744
+ 204: "No Content",
1745
+ 301: "Moved Permanently",
1746
+ 302: "Found",
1747
+ 303: "See Other",
1748
+ 307: "Temporary Redirect",
1749
+ 308: "Permanent Redirect",
1750
+ 400: "Bad Request",
1751
+ 401: "Unauthorized",
1752
+ 403: "Forbidden",
1753
+ 404: "Not Found",
1754
+ 405: "Method Not Allowed",
1755
+ 429: "Too Many Requests",
1756
+ 500: "Internal Server Error",
1757
+ 502: "Bad Gateway",
1758
+ 503: "Service Unavailable"
1759
+ };
1760
+ return map[status] ?? String(status);
1761
+ }
1762
+ async function fetchWithRedirects(url, timeoutMs, signal) {
1763
+ let current = url;
1764
+ let hops = 0;
1765
+ const MAX_REDIRECTS = 5;
1766
+ const originalHost = new URL(url).hostname;
1767
+ while (hops <= MAX_REDIRECTS) {
1768
+ const response = await fetch(current, {
1769
+ redirect: "manual",
1770
+ signal,
1771
+ headers: {
1772
+ Accept: "text/html, text/plain, text/markdown, application/xhtml+xml, */*",
1773
+ "User-Agent": "ecode-web-fetch/0.1"
1616
1774
  }
1617
- const assistantMsg = {
1618
- role: "assistant",
1619
- content: assistantText || null,
1620
- tool_calls: toolCalls.map((tc) => ({
1621
- id: tc.id,
1622
- type: "function",
1623
- function: { name: tc.name, arguments: tc.arguments }
1624
- })),
1625
- ...assistantReasoning ? { reasoning_content: assistantReasoning } : {}
1626
- };
1627
- messages.push(assistantMsg);
1628
- appendLog(logger, {
1629
- role: "assistant",
1630
- content: assistantText || null,
1631
- tool_calls: assistantMsg.tool_calls
1632
- });
1633
- for (const tc of toolCalls) {
1634
- const toolResult = await executeSubagentToolCall(tc.name, tc.arguments, autoApproveNormal);
1635
- const finalResult = toolResult || SKIP_MESSAGE;
1636
- messages.push({ role: "tool", tool_call_id: tc.id, content: finalResult });
1637
- appendLog(logger, {
1638
- role: "tool",
1639
- content: finalResult,
1640
- tool_call_id: tc.id
1641
- });
1775
+ });
1776
+ const status = response.status;
1777
+ if (status >= 300 && status < 400) {
1778
+ const location = response.headers.get("location");
1779
+ if (!location) {
1780
+ return { response, finalUrl: current };
1642
1781
  }
1643
- if (logger) {
1644
- updateSessionMetadata(logger.filePath, {
1645
- lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
1646
- turnCount: turn + 1
1647
- });
1782
+ const nextUrl = new URL(location, current).toString();
1783
+ const nextHost = new URL(nextUrl).hostname;
1784
+ if (!isSameOrWwwHost(originalHost, nextHost)) {
1785
+ throw new Error(`redirect to different host is not allowed automatically (-> ${nextUrl})`);
1648
1786
  }
1787
+ current = nextUrl;
1788
+ hops++;
1789
+ continue;
1649
1790
  }
1650
- return [
1651
- `description: ${params.description}`,
1652
- `model: ${params.model ?? profile.model}`,
1653
- logger ? `log_file: ${logger.filePath}` : void 0,
1654
- "",
1655
- "<task_result>",
1656
- `Subagent stopped after reaching max_turns=${maxTurns} without producing a final answer.`,
1657
- "</task_result>"
1658
- ].filter((line) => line !== void 0).join("\n");
1791
+ return { response, finalUrl: current };
1792
+ }
1793
+ throw new Error(`too many redirects (> ${MAX_REDIRECTS})`);
1794
+ }
1795
+ async function webFetch(params) {
1796
+ const {
1797
+ url,
1798
+ extract_mode = "markdown",
1799
+ max_chars: rawMaxChars = 2e4,
1800
+ timeout_ms: rawTimeout = 2e4
1801
+ } = params;
1802
+ const maxChars = Math.min(Math.max(rawMaxChars, 1), 1e5);
1803
+ const timeoutMs = Math.min(Math.max(rawTimeout, 1e3), 6e4);
1804
+ const validation = validateWebFetchUrl(url);
1805
+ if (!validation.ok) {
1806
+ return `Error fetching ${url}: ${validation.error}`;
1807
+ }
1808
+ const cacheKey = `${url}:${extract_mode}:${maxChars}`;
1809
+ const cached = cacheGet(cacheKey);
1810
+ if (cached !== void 0) return cached;
1811
+ const controller = new AbortController();
1812
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1813
+ let response;
1814
+ let finalUrl;
1815
+ try {
1816
+ ({ response, finalUrl } = await fetchWithRedirects(url, timeoutMs, controller.signal));
1659
1817
  } catch (err) {
1818
+ clearTimeout(timer);
1819
+ if (err instanceof Error && err.name === "AbortError") {
1820
+ return `Error fetching ${url}: request timed out after ${timeoutMs}ms`;
1821
+ }
1660
1822
  const msg = err instanceof Error ? err.message : String(err);
1661
- return [
1662
- `description: ${params.description}`,
1663
- logger ? `log_file: ${logger.filePath}` : void 0,
1664
- "",
1665
- "<task_result>",
1666
- `Subagent failed: ${msg}`,
1667
- "</task_result>"
1668
- ].filter((line) => line !== void 0).join("\n");
1823
+ return `Error fetching ${url}: ${msg}`;
1669
1824
  } finally {
1670
- process.chdir(originalCwd);
1825
+ clearTimeout(timer);
1826
+ }
1827
+ if (response.url) {
1828
+ let resolvedHost;
1829
+ try {
1830
+ resolvedHost = new URL(response.url).hostname;
1831
+ } catch {
1832
+ resolvedHost = validation.parsed.hostname;
1833
+ }
1834
+ if (!isSameOrWwwHost(validation.parsed.hostname, resolvedHost)) {
1835
+ return `Error fetching ${url}: redirect to different host is not allowed automatically (-> ${response.url})`;
1836
+ }
1671
1837
  }
1838
+ const status = response.status;
1839
+ const contentTypeHeader = response.headers.get("content-type") ?? "";
1840
+ const mimeType = contentTypeHeader.split(";")[0].trim().toLowerCase();
1841
+ if (status >= 400) {
1842
+ return `Error fetching ${url}: HTTP ${status} ${statusText(status)}`;
1843
+ }
1844
+ const supportedTypes = ["text/html", "text/plain", "text/markdown", "application/xhtml+xml"];
1845
+ if (mimeType && !supportedTypes.includes(mimeType)) {
1846
+ return `Error fetching ${url}: unsupported content-type ${mimeType}`;
1847
+ }
1848
+ let body;
1849
+ try {
1850
+ body = await response.text();
1851
+ } catch (err) {
1852
+ const msg = err instanceof Error ? err.message : String(err);
1853
+ return `Error fetching ${url}: failed to read response body: ${msg}`;
1854
+ }
1855
+ let extracted;
1856
+ if (mimeType === "text/plain" || mimeType === "text/markdown") {
1857
+ extracted = body;
1858
+ } else {
1859
+ extracted = extractHtmlText(body, extract_mode);
1860
+ }
1861
+ const truncated = extracted.length > maxChars;
1862
+ const content = truncated ? extracted.slice(0, maxChars) : extracted;
1863
+ const result = [
1864
+ `URL: ${url}`,
1865
+ `Final-URL: ${finalUrl}`,
1866
+ `Status: ${status} ${statusText(status)}`,
1867
+ `Content-Type: ${contentTypeHeader || "(none)"}`,
1868
+ `Bytes: ${body.length}`,
1869
+ `Extract-Mode: ${extract_mode}`,
1870
+ `Truncated: ${truncated ? "yes" : "no"}`,
1871
+ "",
1872
+ "<content>",
1873
+ content,
1874
+ "</content>"
1875
+ ].join("\n");
1876
+ cacheSet(cacheKey, result);
1877
+ return result;
1672
1878
  }
1673
1879
 
1674
1880
  // src/repl.ts
1675
- var SKIP_MESSAGE2 = "Command skipped by user.";
1676
- var BASH_TOOL2 = {
1881
+ var SKIP_MESSAGE = "Command skipped by user.";
1882
+ var BASH_TOOL = {
1677
1883
  type: "function",
1678
1884
  function: {
1679
1885
  name: "bash",
@@ -1694,16 +1900,16 @@ async function handleBashTool(command, deps) {
1694
1900
  if (!autoApproveNormal) {
1695
1901
  const ok = await confirm(`Execute command: ${command}
1696
1902
  Proceed? (y/n) `);
1697
- if (!ok) return SKIP_MESSAGE2;
1903
+ if (!ok) return SKIP_MESSAGE;
1698
1904
  }
1699
1905
  } else if (cls === "danger") {
1700
1906
  print(`\u26A0\uFE0F DANGEROUS COMMAND: ${command}`);
1701
1907
  const first = await confirm("Are you sure? (y/n) ");
1702
- if (!first) return SKIP_MESSAGE2;
1908
+ if (!first) return SKIP_MESSAGE;
1703
1909
  const second = await confirm(
1704
1910
  "Confirm again \u2014 this is destructive. Continue? (y/n) "
1705
1911
  );
1706
- if (!second) return SKIP_MESSAGE2;
1912
+ if (!second) return SKIP_MESSAGE;
1707
1913
  }
1708
1914
  const result = await deps.executeBash(command);
1709
1915
  let output = "";
@@ -1730,12 +1936,12 @@ function parseSkillCommand(input) {
1730
1936
  }
1731
1937
 
1732
1938
  // src/skills/handler.ts
1733
- function handleSkillInput(input, registry2) {
1939
+ function handleSkillInput(input, registry) {
1734
1940
  if (!isSkillCommand(input)) return { type: "passthrough" };
1735
1941
  const { name, args } = parseSkillCommand(input);
1736
- const skill = registry2.find(name);
1942
+ const skill = registry.find(name);
1737
1943
  if (!skill) {
1738
- const available = registry2.list().map((s) => s.name).join(", ") || "none";
1944
+ const available = registry.list().map((s) => s.name).join(", ") || "none";
1739
1945
  return {
1740
1946
  type: "error",
1741
1947
  message: `Unknown skill: /${name}. Available: ${available}`
@@ -1754,7 +1960,7 @@ import { promisify } from "util";
1754
1960
 
1755
1961
  // src/skills/loader.ts
1756
1962
  import { readFile as readFile6, readdir as readdir3, stat as stat3 } from "fs/promises";
1757
- import { join as join6, dirname as dirname4, basename as basename2, resolve as resolve3, sep as sep3 } from "path";
1963
+ import { join as join6, dirname as dirname4, basename as basename3, resolve as resolve3, sep as sep3 } from "path";
1758
1964
  function parseFrontmatter(content) {
1759
1965
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
1760
1966
  const match = FRONTMATTER_RE.exec(content);
@@ -1827,7 +2033,7 @@ async function loadSkillFile(skillMdPath) {
1827
2033
  const content = await readFile6(skillMdPath, "utf-8");
1828
2034
  const { data, body } = parseFrontmatter(content);
1829
2035
  const skillDir = dirname4(skillMdPath);
1830
- const dirName = basename2(skillDir);
2036
+ const dirName = basename3(skillDir);
1831
2037
  const [tools, hasPreScript, hasPostScript] = await Promise.all([
1832
2038
  loadTools(skillDir),
1833
2039
  fileExists(join6(skillDir, "pre.sh")),
@@ -2446,7 +2652,7 @@ function dismiss(state) {
2446
2652
 
2447
2653
  // src/ui/fileCompletion.ts
2448
2654
  import * as fs9 from "fs/promises";
2449
- import * as path7 from "path";
2655
+ import * as path8 from "path";
2450
2656
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
2451
2657
  function isHidden(name) {
2452
2658
  return name.startsWith(".");
@@ -2462,17 +2668,17 @@ async function walkDir2(dir, root, results, maxResults) {
2462
2668
  for (const entry of entries) {
2463
2669
  if (results.length >= maxResults) return;
2464
2670
  if (SKIP_DIRS.has(entry.name) || isHidden(entry.name)) continue;
2465
- const relPath = path7.relative(root, path7.join(dir, entry.name));
2671
+ const relPath = path8.relative(root, path8.join(dir, entry.name));
2466
2672
  if (entry.isDirectory()) {
2467
2673
  results.push({ path: relPath, isDir: true });
2468
- await walkDir2(path7.join(dir, entry.name), root, results, maxResults);
2674
+ await walkDir2(path8.join(dir, entry.name), root, results, maxResults);
2469
2675
  } else {
2470
2676
  results.push({ path: relPath, isDir: false });
2471
2677
  }
2472
2678
  }
2473
2679
  }
2474
2680
  async function listFilesForQuery(query, cwd, maxResults = 50) {
2475
- if (path7.isAbsolute(query)) {
2681
+ if (path8.isAbsolute(query)) {
2476
2682
  return listAbsolute(query, maxResults);
2477
2683
  }
2478
2684
  const all = [];
@@ -2486,12 +2692,12 @@ async function listAbsolute(query, maxResults) {
2486
2692
  try {
2487
2693
  const stat5 = await fs9.stat(dir);
2488
2694
  if (!stat5.isDirectory()) {
2489
- filter = path7.basename(dir);
2490
- dir = path7.dirname(dir);
2695
+ filter = path8.basename(dir);
2696
+ dir = path8.dirname(dir);
2491
2697
  }
2492
2698
  } catch {
2493
- filter = path7.basename(dir);
2494
- dir = path7.dirname(dir);
2699
+ filter = path8.basename(dir);
2700
+ dir = path8.dirname(dir);
2495
2701
  }
2496
2702
  let entries;
2497
2703
  try {
@@ -2504,7 +2710,7 @@ async function listAbsolute(query, maxResults) {
2504
2710
  if (SKIP_DIRS.has(entry.name) || isHidden(entry.name)) continue;
2505
2711
  if (filter && !entry.name.includes(filter)) continue;
2506
2712
  results.push({
2507
- path: path7.join(dir, entry.name),
2713
+ path: path8.join(dir, entry.name),
2508
2714
  isDir: entry.isDirectory()
2509
2715
  });
2510
2716
  if (results.length >= maxResults) break;
@@ -2526,7 +2732,7 @@ async function expandFileRefs(text, cwd) {
2526
2732
  atPattern.lastIndex = 0;
2527
2733
  while ((match = atPattern.exec(text)) !== null) {
2528
2734
  const filePath = match[1];
2529
- const fullPath = path7.isAbsolute(filePath) ? filePath : path7.join(cwd, filePath);
2735
+ const fullPath = path8.isAbsolute(filePath) ? filePath : path8.join(cwd, filePath);
2530
2736
  let replacement;
2531
2737
  try {
2532
2738
  const content = await fs9.readFile(fullPath, "utf8");
@@ -2648,7 +2854,7 @@ async function removeJob(dataDir, id) {
2648
2854
  }
2649
2855
 
2650
2856
  // src/automation/runtime.ts
2651
- import { randomUUID as randomUUID2 } from "crypto";
2857
+ import { randomUUID as randomUUID3 } from "crypto";
2652
2858
 
2653
2859
  // src/automation/log.ts
2654
2860
  import { readFile as readFile9, appendFile, mkdir as mkdir3 } from "fs/promises";
@@ -2667,7 +2873,7 @@ async function executeJob(job, config2) {
2667
2873
  return null;
2668
2874
  }
2669
2875
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2670
- const runId = randomUUID2();
2876
+ const runId = randomUUID3();
2671
2877
  let summaryText;
2672
2878
  let errorMsg;
2673
2879
  let result;
@@ -2874,7 +3080,7 @@ var LoopScheduler = class {
2874
3080
  };
2875
3081
 
2876
3082
  // src/automation/loop/command.ts
2877
- import { randomUUID as randomUUID3 } from "crypto";
3083
+ import { randomUUID as randomUUID4 } from "crypto";
2878
3084
 
2879
3085
  // src/automation/loop/parse.ts
2880
3086
  var DEFAULT_INTERVAL_MS = 6e5;
@@ -2965,7 +3171,7 @@ async function cmdLoop(args, deps) {
2965
3171
  }
2966
3172
  const now = /* @__PURE__ */ new Date();
2967
3173
  const job = {
2968
- id: randomUUID3(),
3174
+ id: randomUUID4(),
2969
3175
  kind: "loop",
2970
3176
  title: prompt.slice(0, 60),
2971
3177
  createdAt: now.toISOString(),
@@ -3009,7 +3215,7 @@ async function cmdUnloop(idOrPrefix, deps) {
3009
3215
  }
3010
3216
 
3011
3217
  // src/automation/goal/command.ts
3012
- import { randomUUID as randomUUID4 } from "crypto";
3218
+ import { randomUUID as randomUUID5 } from "crypto";
3013
3219
  async function cmdGoal(args, deps) {
3014
3220
  const condition = args.trim();
3015
3221
  if (!condition) {
@@ -3017,7 +3223,7 @@ async function cmdGoal(args, deps) {
3017
3223
  }
3018
3224
  const now = /* @__PURE__ */ new Date();
3019
3225
  const job = {
3020
- id: randomUUID4(),
3226
+ id: randomUUID5(),
3021
3227
  kind: "goal",
3022
3228
  title: condition.slice(0, 60),
3023
3229
  createdAt: now.toISOString(),
@@ -3271,7 +3477,7 @@ var AutomationManager = class {
3271
3477
 
3272
3478
  // src/meta_skill/index.ts
3273
3479
  import * as fs11 from "fs";
3274
- import * as path9 from "path";
3480
+ import * as path10 from "path";
3275
3481
  import { fileURLToPath } from "url";
3276
3482
 
3277
3483
  // src/meta_skill/task-classifier.ts
@@ -3982,23 +4188,23 @@ function materialize(policy, profile, input) {
3982
4188
 
3983
4189
  // src/meta_skill/learning-store.ts
3984
4190
  import * as fs10 from "fs";
3985
- import * as path8 from "path";
4191
+ import * as path9 from "path";
3986
4192
  import * as os from "os";
3987
- var STORE_PATH = path8.resolve(os.homedir(), ".ecode", "learning-records.jsonl");
4193
+ var STORE_PATH = path9.resolve(os.homedir(), ".ecode", "learning-records.jsonl");
3988
4194
 
3989
4195
  // src/meta_skill/index.ts
3990
4196
  function resolveBuiltinSkillsDir() {
3991
4197
  try {
3992
- let dir = path9.dirname(fileURLToPath(import.meta.url));
4198
+ let dir = path10.dirname(fileURLToPath(import.meta.url));
3993
4199
  for (let i = 0; i < 4; i++) {
3994
- const candidate = path9.join(dir, "skills");
4200
+ const candidate = path10.join(dir, "skills");
3995
4201
  if (fs11.existsSync(candidate) && fs11.statSync(candidate).isDirectory()) {
3996
4202
  const hasSkillContent = fs11.readdirSync(candidate).some(
3997
- (entry) => fs11.existsSync(path9.join(candidate, entry, "SKILL.md"))
4203
+ (entry) => fs11.existsSync(path10.join(candidate, entry, "SKILL.md"))
3998
4204
  );
3999
4205
  if (hasSkillContent) return candidate;
4000
4206
  }
4001
- const parent = path9.dirname(dir);
4207
+ const parent = path10.dirname(dir);
4002
4208
  if (parent === dir) break;
4003
4209
  dir = parent;
4004
4210
  }
@@ -4009,16 +4215,16 @@ function resolveBuiltinSkillsDir() {
4009
4215
  function runMetaAlign(input) {
4010
4216
  const taskType = input.task.type && isValidTaskType(input.task.type) ? input.task.type : classifyTask(input.task.goal);
4011
4217
  const inheritedSkills = getBaselineSkills(taskType);
4012
- const builtinSkillsDir2 = resolveBuiltinSkillsDir();
4218
+ const builtinSkillsDir = resolveBuiltinSkillsDir();
4013
4219
  const allSteps = [];
4014
4220
  for (const skillName of inheritedSkills) {
4015
4221
  const candidates = [];
4016
- if (builtinSkillsDir2) {
4017
- candidates.push(path9.join(builtinSkillsDir2, skillName, "SKILL.md"));
4018
- candidates.push(path9.join(builtinSkillsDir2, skillName + ".md"));
4222
+ if (builtinSkillsDir) {
4223
+ candidates.push(path10.join(builtinSkillsDir, skillName, "SKILL.md"));
4224
+ candidates.push(path10.join(builtinSkillsDir, skillName + ".md"));
4019
4225
  }
4020
- candidates.push(path9.join(process.cwd(), "skills", skillName, "SKILL.md"));
4021
- candidates.push(path9.join(process.cwd(), "skills", skillName + ".md"));
4226
+ candidates.push(path10.join(process.cwd(), "skills", skillName, "SKILL.md"));
4227
+ candidates.push(path10.join(process.cwd(), "skills", skillName + ".md"));
4022
4228
  let body = "";
4023
4229
  for (const p of candidates) {
4024
4230
  if (fs11.existsSync(p)) {
@@ -4179,11 +4385,11 @@ function formatMetaAlignOutput(result, model) {
4179
4385
  );
4180
4386
  return lines.join("\n");
4181
4387
  }
4182
- function App({ config: config2, version: version2, autoMode: autoMode2 = false, registry: registry2, trustedSkillDirs: trustedSkillDirs2 = [], initialMessages: initialMessages2 = [], llmClient }) {
4388
+ function App({ config: config2, version: version2, autoMode: autoMode2 = false, registry, trustedSkillDirs = [], initialMessages = [], llmClient }) {
4183
4389
  const { stdout } = useStdout();
4184
4390
  const { stdin } = useStdin();
4185
4391
  const historyMaxHeight = Math.max(5, (stdout?.rows ?? 24) - 4);
4186
- const [messages, setMessages] = useState3(initialMessages2);
4392
+ const [messages, setMessages] = useState3(initialMessages);
4187
4393
  const [status, setStatus] = useState3("idle");
4188
4394
  const contextLimit = getContextLimit(config2.model, config2.contextLimit);
4189
4395
  const [tokenUsage, setTokenUsage] = useState3({
@@ -4347,7 +4553,7 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
4347
4553
  return;
4348
4554
  }
4349
4555
  }
4350
- const skillList = registry2?.list() ?? [];
4556
+ const skillList = registry?.list() ?? [];
4351
4557
  const suggestions = computeSuggestions(skillList, acState);
4352
4558
  const open = isOpen(acState, suggestions);
4353
4559
  if (open) {
@@ -4452,7 +4658,7 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
4452
4658
  function: { name: t.name, description: t.description, parameters: t.parameters }
4453
4659
  }));
4454
4660
  try {
4455
- for await (const chunk of llmRef.current.stream(currentMessages, [BASH_TOOL2, READ_TOOL, WRITE_TOOL, EDIT_TOOL, GLOB_TOOL, GREP_TOOL, APPLY_PATCH_TOOL, TODO_TOOL, TASK_TOOL, ...dynamicTools], abortController.signal)) {
4661
+ for await (const chunk of llmRef.current.stream(currentMessages, [BASH_TOOL, READ_TOOL, WRITE_TOOL, EDIT_TOOL, GLOB_TOOL, GREP_TOOL, APPLY_PATCH_TOOL, TODO_TOOL, TASK_TOOL, WEB_FETCH_TOOL, ...dynamicTools], abortController.signal)) {
4456
4662
  if (chunk.text) {
4457
4663
  assistantText += chunk.text;
4458
4664
  setMessages((prev) => {
@@ -4555,11 +4761,14 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
4555
4761
  toolResult = todo(args);
4556
4762
  } else if (tc.name === "task") {
4557
4763
  const args = JSON.parse(tc.arguments);
4558
- toolResult = await runTaskTool(args, {
4559
- config: config2,
4560
- parentMessages: currentMessages,
4561
- autoApproveNormal: autoMode2
4764
+ toolResult = await handleTaskTool(args, {
4765
+ provider: llmRef.current,
4766
+ print: (text) => process.stdout.write(text),
4767
+ logDir: config2.logDir
4562
4768
  });
4769
+ } else if (tc.name === "web_fetch") {
4770
+ const args = JSON.parse(tc.arguments);
4771
+ toolResult = await webFetch(args);
4563
4772
  } else {
4564
4773
  const skillTool = skillToolsRef.current.find((t) => t.name === tc.name);
4565
4774
  if (skillTool) {
@@ -4568,7 +4777,7 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
4568
4777
  toolArgs = JSON.parse(tc.arguments);
4569
4778
  } catch {
4570
4779
  }
4571
- toolResult = await executeSkillTool(skillTool, toolArgs, trustedSkillDirs2);
4780
+ toolResult = await executeSkillTool(skillTool, toolArgs, trustedSkillDirs);
4572
4781
  } else {
4573
4782
  toolResult = `Unknown tool: ${tc.name}`;
4574
4783
  }
@@ -4670,8 +4879,8 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
4670
4879
  return;
4671
4880
  }
4672
4881
  }
4673
- if (registry2) {
4674
- const skillResult = handleSkillInput(trimmed, registry2);
4882
+ if (registry) {
4883
+ const skillResult = handleSkillInput(trimmed, registry);
4675
4884
  if (skillResult.type === "error") {
4676
4885
  setMessages((prev) => [
4677
4886
  ...prev,
@@ -4684,7 +4893,7 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
4684
4893
  setSkillTools(skill.tools);
4685
4894
  if (skill.preScript) {
4686
4895
  try {
4687
- await executePreScript(skill, trustedSkillDirs2);
4896
+ await executePreScript(skill, trustedSkillDirs);
4688
4897
  } catch (preErr) {
4689
4898
  setMessages((prev) => [
4690
4899
  ...prev,
@@ -4741,7 +4950,7 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
4741
4950
  setFileAcState((prev) => handleInputChange2(prev, text));
4742
4951
  }
4743
4952
  }, [status]);
4744
- const skillSuggestions = registry2 ? computeSuggestions(registry2.list(), acState) : [];
4953
+ const skillSuggestions = registry ? computeSuggestions(registry.list(), acState) : [];
4745
4954
  const acOpen = isOpen(acState, skillSuggestions);
4746
4955
  return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", height: "100%", children: [
4747
4956
  /* @__PURE__ */ jsx6(Box6, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-end", children: /* @__PURE__ */ jsx6(
@@ -4876,7 +5085,7 @@ var SkillRegistry = class {
4876
5085
 
4877
5086
  // src/pipe.ts
4878
5087
  var PIPE_SYSTEM_PROMPT = "You are a helpful assistant running in headless pipe mode. You have access to read-only file tools (read, glob, grep). Answer concisely and accurately. Follow the user's requested language, format, and length exactly. Do not reveal chain-of-thought or emit raw <think> / </think> tags in the final answer; if you must refer to them, describe them in plain language instead.";
4879
- var PIPE_TOOLS = [READ_TOOL, GLOB_TOOL, GREP_TOOL];
5088
+ var PIPE_TOOLS = [READ_TOOL, GLOB_TOOL, GREP_TOOL, WEB_FETCH_TOOL];
4880
5089
  function emit(out, event) {
4881
5090
  out.write(JSON.stringify(event) + "\n");
4882
5091
  }
@@ -4893,6 +5102,10 @@ async function executeToolCall(name, args) {
4893
5102
  const parsed = JSON.parse(args);
4894
5103
  return grepFiles(parsed);
4895
5104
  }
5105
+ if (name === "web_fetch") {
5106
+ const parsed = JSON.parse(args);
5107
+ return webFetch(parsed);
5108
+ }
4896
5109
  return `Unknown tool: ${name}`;
4897
5110
  }
4898
5111
  async function runPipe(prompt, llm, out = process.stdout, systemPrompt) {
@@ -4963,7 +5176,7 @@ function readStdin() {
4963
5176
  }
4964
5177
 
4965
5178
  // src/sessions/command.ts
4966
- import * as path10 from "path";
5179
+ import * as path11 from "path";
4967
5180
  function cmdSessionsList(logDir) {
4968
5181
  const sessions = listSessions(logDir);
4969
5182
  if (sessions.length === 0) return "(no sessions found)";
@@ -4985,13 +5198,13 @@ function cmdSessionsInspect(logDir, idOrPrefix) {
4985
5198
  function cmdSessionsReplay(logDir, idOrPrefix) {
4986
5199
  const session = findSession(logDir, idOrPrefix);
4987
5200
  if (!session) return `Session not found: ${idOrPrefix}`;
4988
- const logPath = path10.join(logDir, session.logFile);
5201
+ const logPath = path11.join(logDir, session.logFile);
4989
5202
  return `ecode --replay ${logPath}`;
4990
5203
  }
4991
5204
  function cmdSessionsFork(logDir, idOrPrefix, turn) {
4992
5205
  const session = findSession(logDir, idOrPrefix);
4993
5206
  if (!session) return `Session not found: ${idOrPrefix}`;
4994
- const logPath = path10.join(logDir, session.logFile);
5207
+ const logPath = path11.join(logDir, session.logFile);
4995
5208
  const turnStr = turn !== void 0 ? turn : session.turnCount;
4996
5209
  return `ecode --fork ${logPath}:${turnStr}`;
4997
5210
  }
@@ -5005,15 +5218,777 @@ function formatInspect(session, logDir) {
5005
5218
  `last active: ${new Date(session.lastActivity).toLocaleString()}`,
5006
5219
  `turns: ${session.turnCount}`,
5007
5220
  `tokens: ${session.totalTokens.toLocaleString()}`,
5008
- `log file: ${path10.join(logDir, session.logFile)}`
5221
+ `log file: ${path11.join(logDir, session.logFile)}`
5009
5222
  ].join("\n");
5010
5223
  }
5011
5224
 
5225
+ // src/web/server.ts
5226
+ import Fastify from "fastify";
5227
+ import cors from "@fastify/cors";
5228
+ import websocket from "@fastify/websocket";
5229
+
5230
+ // src/web/auth.ts
5231
+ import { randomUUID as randomUUID6 } from "crypto";
5232
+ function generateAccessToken() {
5233
+ return randomUUID6();
5234
+ }
5235
+ function createAuthHook(token) {
5236
+ return function authHook(request, reply, done) {
5237
+ const incoming = request.headers["x-ecode-token"];
5238
+ if (incoming !== token) {
5239
+ reply.code(401).send({ success: false, error: "Unauthorized" });
5240
+ return;
5241
+ }
5242
+ done();
5243
+ };
5244
+ }
5245
+
5246
+ // src/web/routes/status.ts
5247
+ async function statusRoutes(app, opts) {
5248
+ app.get(
5249
+ "/api/status",
5250
+ async (_request, _reply) => {
5251
+ const runningSessions = opts.manager.listRunning().length;
5252
+ return {
5253
+ version: opts.version,
5254
+ cwd: process.cwd(),
5255
+ model: opts.config.model,
5256
+ logDir: opts.config.logDir,
5257
+ defaultProvider: opts.config.defaultProvider,
5258
+ runningSessions
5259
+ };
5260
+ }
5261
+ );
5262
+ }
5263
+
5264
+ // src/web/routes/sessions.ts
5265
+ import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
5266
+ import { join as join13 } from "path";
5267
+ async function sessionsRoutes(app, opts) {
5268
+ app.get("/api/sessions", async (_request, reply) => {
5269
+ if (!opts.config.logDir) {
5270
+ return reply.send([]);
5271
+ }
5272
+ return listSessions(opts.config.logDir);
5273
+ });
5274
+ app.get(
5275
+ "/api/sessions/:id/messages",
5276
+ async (request, reply) => {
5277
+ const { id } = request.params;
5278
+ if (!opts.config.logDir) {
5279
+ return reply.code(404).send({ success: false, error: "Log directory not configured" });
5280
+ }
5281
+ const session = findSession(opts.config.logDir, id);
5282
+ if (!session) {
5283
+ return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
5284
+ }
5285
+ const logFilePath2 = join13(opts.config.logDir, session.logFile);
5286
+ if (!existsSync4(logFilePath2)) {
5287
+ return reply.code(404).send({ success: false, error: `Log file not found: ${session.logFile}` });
5288
+ }
5289
+ try {
5290
+ const content = readFileSync5(logFilePath2, "utf-8");
5291
+ return reply.header("Content-Type", "text/plain; charset=utf-8").send(content);
5292
+ } catch (_err) {
5293
+ return reply.code(404).send({ success: false, error: `Log file not found: ${session.logFile}` });
5294
+ }
5295
+ }
5296
+ );
5297
+ app.get(
5298
+ "/api/sessions/:id/replay-command",
5299
+ async (request, reply) => {
5300
+ const { id } = request.params;
5301
+ if (!opts.config.logDir) {
5302
+ return reply.code(404).send({ success: false, error: "Log directory not configured" });
5303
+ }
5304
+ const logDir = opts.config.logDir;
5305
+ const session = findSession(logDir, id);
5306
+ if (!session) {
5307
+ return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
5308
+ }
5309
+ const command = cmdSessionsReplay(logDir, id);
5310
+ return reply.send({ success: true, command });
5311
+ }
5312
+ );
5313
+ app.get(
5314
+ "/api/sessions/:id/fork-command",
5315
+ async (request, reply) => {
5316
+ const { id } = request.params;
5317
+ if (!opts.config.logDir) {
5318
+ return reply.code(404).send({ success: false, error: "Log directory not configured" });
5319
+ }
5320
+ const logDir = opts.config.logDir;
5321
+ const session = findSession(logDir, id);
5322
+ if (!session) {
5323
+ return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
5324
+ }
5325
+ let turn;
5326
+ if (request.query.turn !== void 0) {
5327
+ const parsed = parseInt(request.query.turn, 10);
5328
+ if (isNaN(parsed)) {
5329
+ return reply.code(400).send({ success: false, error: `Invalid turn value: ${request.query.turn}` });
5330
+ }
5331
+ turn = parsed;
5332
+ }
5333
+ const command = cmdSessionsFork(logDir, id, turn);
5334
+ return reply.send({ success: true, command });
5335
+ }
5336
+ );
5337
+ }
5338
+
5339
+ // src/web/routes/config.ts
5340
+ function toSafeConfig(cfg) {
5341
+ const { apiKey: _stripped, ...safe } = cfg;
5342
+ return safe;
5343
+ }
5344
+ async function configRoutes(app, opts) {
5345
+ app.get("/api/config", async (_request, _reply) => {
5346
+ const { apiKey: _stripped, ...safeConfig } = opts.config;
5347
+ return safeConfig;
5348
+ });
5349
+ app.put(
5350
+ "/api/config",
5351
+ async (request, reply) => {
5352
+ const body = request.body;
5353
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
5354
+ return reply.status(400).send(toSafeConfig(opts.config));
5355
+ }
5356
+ const merged = { ...opts.config, ...body };
5357
+ opts.save?.(merged);
5358
+ Object.assign(opts.config, merged);
5359
+ return toSafeConfig(merged);
5360
+ }
5361
+ );
5362
+ }
5363
+
5364
+ // src/web/routes/automation.ts
5365
+ async function automationRoutes(app, opts) {
5366
+ app.get(
5367
+ "/api/automation/jobs",
5368
+ async (_request, reply) => {
5369
+ if (!opts.config.logDir) {
5370
+ return reply.send([]);
5371
+ }
5372
+ const jobs = await loadJobs(opts.config.logDir);
5373
+ return jobs;
5374
+ }
5375
+ );
5376
+ app.post(
5377
+ "/api/automation/jobs/:id/pause",
5378
+ async (request, reply) => {
5379
+ const logDir = opts.config.logDir;
5380
+ if (!logDir) {
5381
+ return reply.status(404).send({ error: "logDir not configured" });
5382
+ }
5383
+ const { id } = request.params;
5384
+ const jobs = await loadJobs(logDir);
5385
+ const job = jobs.find((j) => j.id === id);
5386
+ if (!job) {
5387
+ return reply.status(404).send({ error: "job not found" });
5388
+ }
5389
+ if (job.state === "paused") {
5390
+ return reply.status(409).send({ error: "job is already paused" });
5391
+ }
5392
+ const updated = {
5393
+ ...job,
5394
+ state: "paused",
5395
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5396
+ };
5397
+ await upsertJob(logDir, updated);
5398
+ return reply.status(200).send({ success: true });
5399
+ }
5400
+ );
5401
+ app.post(
5402
+ "/api/automation/jobs/:id/resume",
5403
+ async (request, reply) => {
5404
+ const logDir = opts.config.logDir;
5405
+ if (!logDir) {
5406
+ return reply.status(404).send({ error: "logDir not configured" });
5407
+ }
5408
+ const { id } = request.params;
5409
+ const jobs = await loadJobs(logDir);
5410
+ const job = jobs.find((j) => j.id === id);
5411
+ if (!job) {
5412
+ return reply.status(404).send({ error: "job not found" });
5413
+ }
5414
+ if (job.state !== "paused") {
5415
+ return reply.status(409).send({ error: "job is not paused" });
5416
+ }
5417
+ const updated = {
5418
+ ...job,
5419
+ state: "scheduled",
5420
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5421
+ };
5422
+ await upsertJob(logDir, updated);
5423
+ return reply.status(200).send({ success: true });
5424
+ }
5425
+ );
5426
+ app.delete(
5427
+ "/api/automation/jobs/:id",
5428
+ async (request, reply) => {
5429
+ const logDir = opts.config.logDir;
5430
+ if (!logDir) {
5431
+ return reply.status(404).send({ error: "logDir not configured" });
5432
+ }
5433
+ const { id } = request.params;
5434
+ const removed = await removeJob(logDir, id);
5435
+ if (!removed) {
5436
+ return reply.status(404).send({ error: "job not found" });
5437
+ }
5438
+ return reply.status(200).send({ success: true });
5439
+ }
5440
+ );
5441
+ }
5442
+
5443
+ // src/web/routes/chat.ts
5444
+ var BUSY_STATUSES = /* @__PURE__ */ new Set(["thinking", "tool_calling", "awaiting_confirm"]);
5445
+ async function chatRoutes(app, opts) {
5446
+ const { manager } = opts;
5447
+ app.post("/api/chat/sessions", async (request, reply) => {
5448
+ const body = request.body ?? {};
5449
+ const sessionOpts = {};
5450
+ if (typeof body.systemPrompt === "string") {
5451
+ sessionOpts.systemPrompt = body.systemPrompt;
5452
+ }
5453
+ const runtime = await manager.createSession(sessionOpts);
5454
+ return reply.code(201).send({ success: true, session: runtime.snapshot() });
5455
+ });
5456
+ app.post("/api/chat/sessions/:id/submit", async (request, reply) => {
5457
+ const { id } = request.params;
5458
+ const body = request.body ?? {};
5459
+ if (typeof body.message !== "string" || body.message.trim() === "") {
5460
+ return reply.code(400).send({ success: false, error: "message is required and must be a non-empty string" });
5461
+ }
5462
+ const session = manager.getSession(id);
5463
+ if (!session) {
5464
+ return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
5465
+ }
5466
+ if (BUSY_STATUSES.has(session.status)) {
5467
+ return reply.code(409).send({ success: false, error: `Session is busy: ${session.status}` });
5468
+ }
5469
+ session.submit(body.message).catch((err) => {
5470
+ app.log.error({ err, sessionId: id }, "submit \u610F\u5916\u5931\u8D25");
5471
+ });
5472
+ return reply.code(202).send({ success: true, queued: true });
5473
+ });
5474
+ app.post(
5475
+ "/api/chat/sessions/:id/interrupt",
5476
+ async (request, reply) => {
5477
+ const { id } = request.params;
5478
+ const session = manager.getSession(id);
5479
+ if (!session) {
5480
+ return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
5481
+ }
5482
+ session.interrupt();
5483
+ return reply.send({ success: true });
5484
+ }
5485
+ );
5486
+ app.post(
5487
+ "/api/chat/sessions/:id/approve",
5488
+ async (request, reply) => {
5489
+ const { id } = request.params;
5490
+ const body = request.body ?? {};
5491
+ if (typeof body.requestId !== "string") {
5492
+ return reply.code(400).send({ success: false, error: "requestId is required and must be a string" });
5493
+ }
5494
+ if (typeof body.approved !== "boolean") {
5495
+ return reply.code(400).send({ success: false, error: "approved is required and must be a boolean" });
5496
+ }
5497
+ const session = manager.getSession(id);
5498
+ if (!session) {
5499
+ return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
5500
+ }
5501
+ if (session.status !== "awaiting_confirm") {
5502
+ return reply.code(409).send({
5503
+ success: false,
5504
+ error: `Session is not awaiting confirmation: ${session.status}`
5505
+ });
5506
+ }
5507
+ session.approve(body.requestId, body.approved);
5508
+ return reply.send({ success: true });
5509
+ }
5510
+ );
5511
+ }
5512
+
5513
+ // src/web/ws/session-hub.ts
5514
+ async function sessionHubRoutes(app, opts) {
5515
+ app.get(
5516
+ "/api/ws/sessions/:id",
5517
+ { websocket: true },
5518
+ (socket, request) => {
5519
+ const { id } = request.params;
5520
+ const session = opts.manager.getSession(id);
5521
+ if (!session) {
5522
+ socket.close(4404, "session not found");
5523
+ return;
5524
+ }
5525
+ const unsubscribe = session.subscribe((event) => {
5526
+ if (socket.readyState === socket.OPEN) {
5527
+ socket.send(JSON.stringify(event));
5528
+ }
5529
+ });
5530
+ socket.on("close", () => {
5531
+ unsubscribe();
5532
+ });
5533
+ socket.on("message", (_msg) => {
5534
+ });
5535
+ }
5536
+ );
5537
+ }
5538
+
5539
+ // src/web/server.ts
5540
+ async function buildServer(opts) {
5541
+ const app = Fastify({ logger: false });
5542
+ await app.register(cors, { origin: true });
5543
+ await app.register(websocket);
5544
+ const authHook = createAuthHook(opts.token);
5545
+ app.addHook("preHandler", function(request, reply, done) {
5546
+ if (request.url === "/health") {
5547
+ done();
5548
+ return;
5549
+ }
5550
+ authHook(request, reply, done);
5551
+ });
5552
+ app.get("/health", async () => ({ ok: true }));
5553
+ await app.register(statusRoutes, {
5554
+ config: opts.config,
5555
+ manager: opts.manager,
5556
+ version: opts.version
5557
+ });
5558
+ await app.register(sessionsRoutes, { config: opts.config });
5559
+ await app.register(configRoutes, { config: opts.config });
5560
+ await app.register(automationRoutes, { config: opts.config });
5561
+ await app.register(chatRoutes, { config: opts.config, manager: opts.manager });
5562
+ await app.register(sessionHubRoutes, { manager: opts.manager });
5563
+ return app;
5564
+ }
5565
+
5566
+ // src/runtime/events.ts
5567
+ var EventBus = class {
5568
+ listeners = /* @__PURE__ */ new Set();
5569
+ /** 发布事件,同步调用所有当前订阅者 */
5570
+ emit(event) {
5571
+ for (const listener of this.listeners) {
5572
+ listener(event);
5573
+ }
5574
+ }
5575
+ /** 添加事件监听器,返回取消订阅函数 */
5576
+ subscribe(listener) {
5577
+ this.listeners.add(listener);
5578
+ return () => {
5579
+ this.listeners.delete(listener);
5580
+ };
5581
+ }
5582
+ /** 当前订阅者数量(测试用) */
5583
+ get size() {
5584
+ return this.listeners.size;
5585
+ }
5586
+ /** 清除所有订阅者 */
5587
+ clear() {
5588
+ this.listeners.clear();
5589
+ }
5590
+ };
5591
+
5592
+ // src/runtime/approvals.ts
5593
+ var ApprovalQueue = class {
5594
+ pending = /* @__PURE__ */ new Map();
5595
+ /**
5596
+ * 发起一个审批请求,返回 Promise。
5597
+ * 调用方 await 此 Promise,直到 resolve() 被外部调用。
5598
+ */
5599
+ request(kind, prompt) {
5600
+ const id = crypto.randomUUID();
5601
+ const promise = new Promise((resolve6) => {
5602
+ this.pending.set(id, {
5603
+ meta: { id, kind, prompt },
5604
+ resolve: resolve6
5605
+ });
5606
+ });
5607
+ return { id, promise };
5608
+ }
5609
+ /**
5610
+ * 外部(CLI readline / Web API)调用此方法来解决一个待处理请求。
5611
+ * 若 id 不存在则静默忽略。
5612
+ */
5613
+ resolve(id, approved) {
5614
+ const entry = this.pending.get(id);
5615
+ if (!entry) return;
5616
+ this.pending.delete(id);
5617
+ entry.resolve(approved);
5618
+ }
5619
+ /** 获取所有待处理请求的只读视图(Web API 用于 GET 查询) */
5620
+ listPending() {
5621
+ return [...this.pending.values()].map((e) => e.meta);
5622
+ }
5623
+ /** 取消所有待处理请求(中断会话时调用),全部视为拒绝 */
5624
+ cancelAll() {
5625
+ for (const entry of this.pending.values()) {
5626
+ entry.resolve(false);
5627
+ }
5628
+ this.pending.clear();
5629
+ }
5630
+ get size() {
5631
+ return this.pending.size;
5632
+ }
5633
+ };
5634
+
5635
+ // src/runtime/session.ts
5636
+ var SESSION_TOOLS = [
5637
+ BASH_TOOL,
5638
+ READ_TOOL,
5639
+ WRITE_TOOL,
5640
+ EDIT_TOOL,
5641
+ GLOB_TOOL,
5642
+ GREP_TOOL,
5643
+ APPLY_PATCH_TOOL,
5644
+ TODO_TOOL,
5645
+ TASK_TOOL,
5646
+ WEB_FETCH_TOOL
5647
+ ];
5648
+ var SessionRuntime = class {
5649
+ id;
5650
+ _status = "idle";
5651
+ bus = new EventBus();
5652
+ approvals = new ApprovalQueue();
5653
+ llm;
5654
+ messages;
5655
+ config;
5656
+ model;
5657
+ abortController = null;
5658
+ _turnCount = 0;
5659
+ _totalTokens = 0;
5660
+ startedAt = (/* @__PURE__ */ new Date()).toISOString();
5661
+ lastActivity = (/* @__PURE__ */ new Date()).toISOString();
5662
+ title = "New Session";
5663
+ constructor(config2, opts = {}) {
5664
+ this.id = opts.sessionId ?? crypto.randomUUID();
5665
+ this.config = config2;
5666
+ this.llm = opts.llm ?? createProvider(resolveActiveProfile(config2));
5667
+ this.model = config2.model;
5668
+ const systemContent = opts.systemPrompt ?? (config2.systemPrompt ?? DEFAULT_SYSTEM_PROMPT);
5669
+ this.messages = systemContent ? [{ role: "system", content: systemContent }] : [];
5670
+ if (opts.initialMessages) {
5671
+ this.messages.push(...opts.initialMessages);
5672
+ }
5673
+ }
5674
+ get status() {
5675
+ return this._status;
5676
+ }
5677
+ /** 订阅事件流,返回取消订阅函数(调用方负责在不需要时取消以防内存泄漏) */
5678
+ subscribe(listener) {
5679
+ return this.bus.subscribe(listener);
5680
+ }
5681
+ /**
5682
+ * 提交用户输入,驱动一轮完整的 agentic 循环。
5683
+ *
5684
+ * 流程:
5685
+ * 1. 将用户消息写入历史
5686
+ * 2. 启动 AbortController(供 interrupt() 使用)
5687
+ * 3. 运行 agentic 循环直到:纯文本回复 / 中断 / 错误
5688
+ * 4. 更新计数器并发出 session.idle
5689
+ */
5690
+ async submit(userInput) {
5691
+ this.messages.push({ role: "user", content: userInput });
5692
+ this.abortController = new AbortController();
5693
+ const signal = this.abortController.signal;
5694
+ try {
5695
+ await this.runAgenticLoop(signal);
5696
+ } catch {
5697
+ if (!signal.aborted) {
5698
+ this._status = "error";
5699
+ this.bus.emit({ type: "session.error", sessionId: this.id, error: "Unexpected error in agentic loop" });
5700
+ }
5701
+ return;
5702
+ } finally {
5703
+ this.abortController = null;
5704
+ }
5705
+ this._turnCount++;
5706
+ this.lastActivity = (/* @__PURE__ */ new Date()).toISOString();
5707
+ this._status = "idle";
5708
+ this.bus.emit({ type: "session.idle", sessionId: this.id });
5709
+ }
5710
+ /**
5711
+ * agentic 循环:持续调用 LLM 并处理工具调用,直到模型给出纯文本回复。
5712
+ *
5713
+ * 每次迭代:
5714
+ * 1. 调用 LLM 流并实时发出 message.delta 事件
5715
+ * 2. 若收到工具调用 → 执行工具 → 追加历史 → 继续循环
5716
+ * 3. 若无工具调用 → 追加 assistant 消息 → 退出循环
5717
+ */
5718
+ async runAgenticLoop(signal) {
5719
+ while (!signal.aborted) {
5720
+ this._status = "thinking";
5721
+ const messageId = crypto.randomUUID();
5722
+ const uiMsg = {
5723
+ id: messageId,
5724
+ role: "assistant",
5725
+ content: null,
5726
+ toolCalls: [],
5727
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
5728
+ };
5729
+ this.bus.emit({ type: "message.created", message: uiMsg });
5730
+ let assistantText = "";
5731
+ let assistantReasoning;
5732
+ let lastReasoningDetails;
5733
+ let lastUsage;
5734
+ const toolCalls = [];
5735
+ for await (const chunk of this.llm.stream(this.messages, SESSION_TOOLS, signal)) {
5736
+ if (signal.aborted) break;
5737
+ if (chunk.text) {
5738
+ this.bus.emit({ type: "message.delta", messageId, text: chunk.text });
5739
+ assistantText += chunk.text;
5740
+ }
5741
+ if (chunk.reasoning) {
5742
+ this.bus.emit({ type: "message.reasoning", messageId, text: chunk.reasoning });
5743
+ }
5744
+ if (chunk.done) {
5745
+ if (chunk.toolCalls) toolCalls.push(...chunk.toolCalls);
5746
+ if (chunk.usage) lastUsage = chunk.usage;
5747
+ if (chunk.reasoning) assistantReasoning = chunk.reasoning;
5748
+ if (chunk.reasoningDetails) lastReasoningDetails = chunk.reasoningDetails;
5749
+ }
5750
+ }
5751
+ if (signal.aborted) break;
5752
+ this.bus.emit({ type: "message.completed", messageId, usage: lastUsage });
5753
+ if (lastUsage) {
5754
+ this._totalTokens += lastUsage.totalTokens;
5755
+ }
5756
+ if (toolCalls.length > 0) {
5757
+ this._status = "tool_calling";
5758
+ this.messages.push({
5759
+ role: "assistant",
5760
+ content: assistantText || null,
5761
+ tool_calls: toolCalls.map((tc) => ({
5762
+ id: tc.id,
5763
+ type: "function",
5764
+ function: { name: tc.name, arguments: tc.arguments }
5765
+ })),
5766
+ ...assistantReasoning ? { reasoning_content: assistantReasoning } : {},
5767
+ ...lastReasoningDetails ? { reasoning_details: lastReasoningDetails } : {}
5768
+ });
5769
+ for (const tc of toolCalls) {
5770
+ if (signal.aborted) break;
5771
+ this.bus.emit({ type: "tool.started", callId: tc.id, toolName: tc.name, argsPreview: tc.arguments });
5772
+ const toolResult = await this.executeToolCall(tc, signal);
5773
+ this.bus.emit({ type: "tool.completed", callId: tc.id, toolName: tc.name, output: toolResult });
5774
+ this.messages.push({ role: "tool", tool_call_id: tc.id, content: toolResult });
5775
+ }
5776
+ } else {
5777
+ if (assistantText) {
5778
+ this.messages.push({
5779
+ role: "assistant",
5780
+ content: assistantText,
5781
+ ...assistantReasoning ? { reasoning_content: assistantReasoning } : {}
5782
+ });
5783
+ }
5784
+ break;
5785
+ }
5786
+ }
5787
+ }
5788
+ /**
5789
+ * 执行单个工具调用并返回结果字符串。
5790
+ *
5791
+ * bash 工具:先通过 ApprovalQueue 请求用户确认(normal/danger 级别),
5792
+ * 再执行并将 stdout/stderr/exitCode 格式化为字符串返回。
5793
+ * 其他工具:直接执行,不需要审批流程。
5794
+ */
5795
+ async executeToolCall(tc, signal) {
5796
+ const { name, arguments: args } = tc;
5797
+ try {
5798
+ if (name === "bash") {
5799
+ const parsed = JSON.parse(args);
5800
+ const cls = classifyCommand(parsed.command, this.config.dangerousPatterns);
5801
+ if (cls === "normal" || cls === "danger") {
5802
+ this._status = "awaiting_confirm";
5803
+ const kind = cls === "danger" ? "danger" : "normal";
5804
+ const prompt = cls === "danger" ? `\u26A0\uFE0F DANGEROUS COMMAND: ${parsed.command}` : `Execute command: ${parsed.command}
5805
+ Proceed?`;
5806
+ const { id: reqId, promise } = this.approvals.request(kind, prompt);
5807
+ this.bus.emit({ type: "approval.requested", requestId: reqId, kind, prompt });
5808
+ const approved = await promise;
5809
+ this._status = "tool_calling";
5810
+ if (!approved) return SKIP_MESSAGE;
5811
+ }
5812
+ if (signal.aborted) return SKIP_MESSAGE;
5813
+ const result = await executeBash(parsed.command);
5814
+ let output = "";
5815
+ if (result.stdout) output += result.stdout;
5816
+ if (result.stderr) output += result.stderr;
5817
+ if (result.exitCode !== 0) output += `
5818
+ [exit code: ${result.exitCode}]`;
5819
+ return output || "(no output)";
5820
+ }
5821
+ if (name === "read") {
5822
+ const parsed = JSON.parse(args);
5823
+ return await readFile2(parsed);
5824
+ }
5825
+ if (name === "write") {
5826
+ const parsed = JSON.parse(args);
5827
+ return await writeFile2(parsed);
5828
+ }
5829
+ if (name === "edit") {
5830
+ const parsed = JSON.parse(args);
5831
+ return await editFile(parsed);
5832
+ }
5833
+ if (name === "glob") {
5834
+ const parsed = JSON.parse(args);
5835
+ return await globFiles(parsed);
5836
+ }
5837
+ if (name === "grep") {
5838
+ const parsed = JSON.parse(args);
5839
+ return await grepFiles(parsed);
5840
+ }
5841
+ if (name === "apply_patch") {
5842
+ const parsed = JSON.parse(args);
5843
+ return await applyPatch(parsed);
5844
+ }
5845
+ if (name === "todo") {
5846
+ const parsed = JSON.parse(args);
5847
+ return todo(parsed);
5848
+ }
5849
+ if (name === "task") {
5850
+ const parsed = JSON.parse(args);
5851
+ return await handleTaskTool(parsed, {
5852
+ provider: this.llm,
5853
+ print: () => {
5854
+ },
5855
+ logDir: this.config.logDir
5856
+ });
5857
+ }
5858
+ if (name === "web_fetch") {
5859
+ const parsed = JSON.parse(args);
5860
+ return await webFetch(parsed);
5861
+ }
5862
+ return `Unknown tool: ${name}`;
5863
+ } catch (err) {
5864
+ return `Tool error: ${String(err)}`;
5865
+ }
5866
+ }
5867
+ /**
5868
+ * 中断当前正在进行的生成。
5869
+ *
5870
+ * 通过 AbortController 通知流式生成停止,
5871
+ * 同时取消所有待处理的审批(防止 await promise 永久阻塞)。
5872
+ * 在 idle 状态调用是安全的(无 active controller 时静默忽略中断)。
5873
+ */
5874
+ interrupt() {
5875
+ this.abortController?.abort();
5876
+ this.approvals.cancelAll();
5877
+ this._status = "idle";
5878
+ this.bus.emit({ type: "session.interrupted", sessionId: this.id });
5879
+ }
5880
+ /** 审批或拒绝一个待处理的确认请求(由 Web API 或 CLI readline 调用) */
5881
+ approve(requestId, approved) {
5882
+ this.approvals.resolve(requestId, approved);
5883
+ this.bus.emit({ type: "approval.resolved", requestId, approved });
5884
+ }
5885
+ /** 返回当前会话的只读快照(用于列表展示和 WebSocket 初始状态同步) */
5886
+ snapshot() {
5887
+ return {
5888
+ id: this.id,
5889
+ title: this.title,
5890
+ model: this.model,
5891
+ status: this._status,
5892
+ turnCount: this._turnCount,
5893
+ totalTokens: this._totalTokens,
5894
+ startedAt: this.startedAt,
5895
+ lastActivity: this.lastActivity
5896
+ };
5897
+ }
5898
+ };
5899
+
5900
+ // src/runtime/manager.ts
5901
+ var SessionManager = class {
5902
+ sessions = /* @__PURE__ */ new Map();
5903
+ config;
5904
+ constructor(config2) {
5905
+ this.config = config2;
5906
+ }
5907
+ /** 创建新会话,注册到内存 Map 并返回 ISessionRuntime */
5908
+ async createSession(opts = {}) {
5909
+ const runtime = new SessionRuntime(this.config, opts);
5910
+ this.sessions.set(runtime.id, runtime);
5911
+ return runtime;
5912
+ }
5913
+ /**
5914
+ * 按 ID 恢复已有会话。
5915
+ * 当前实现仅从内存 Map 查找;若进程重启后需要恢复,
5916
+ * 可扩展为从 sessions/metadata.ts 加载历史消息重建会话。
5917
+ */
5918
+ async resumeSession(sessionId) {
5919
+ const existing = this.sessions.get(sessionId);
5920
+ if (existing) return existing;
5921
+ throw new Error(`Session not found: ${sessionId}`);
5922
+ }
5923
+ /** 按 ID 查找会话,不存在时返回 undefined(不抛错) */
5924
+ getSession(sessionId) {
5925
+ return this.sessions.get(sessionId);
5926
+ }
5927
+ /** 返回所有活跃会话的快照列表(只读,用于列表展示) */
5928
+ listRunning() {
5929
+ return [...this.sessions.values()].map((s) => s.snapshot());
5930
+ }
5931
+ };
5932
+
5012
5933
  // src/index.ts
5013
5934
  var require2 = createRequire(import.meta.url);
5014
5935
  var { version } = require2("../package.json");
5015
5936
  var VERSION = version;
5016
5937
  var rawArgs = process.argv.slice(2);
5938
+ function isHelpFlag(arg) {
5939
+ return arg === "-h" || arg === "--help" || arg === "-help";
5940
+ }
5941
+ function renderRootHelp() {
5942
+ return `ecode \u2014 terminal + web AI coding assistant
5943
+
5944
+ Usage:
5945
+ ecode [options]
5946
+ ecode sessions <subcommand> [args]
5947
+ ecode web [options]
5948
+
5949
+ Options:
5950
+ -h, --help, -help Show help
5951
+ -v, --version Show version
5952
+ --auto Auto-approve normal shell commands
5953
+ --log-dir <dir> Override JSONL log directory
5954
+ --replay <file> Replay a session log
5955
+ --fork <file:turn> Fork a session log at a turn
5956
+ --pipe Read prompt from stdin and output JSONL
5957
+
5958
+ Commands:
5959
+ sessions Inspect, replay, and fork logged sessions
5960
+ web Start the web admin server
5961
+
5962
+ Run \`ecode sessions -h\` or \`ecode web -h\` for command-specific help.`;
5963
+ }
5964
+ function renderSessionsHelp() {
5965
+ return `Usage: ecode sessions <list|inspect|replay|fork> [args...]
5966
+
5967
+ list \u5217\u51FA\u6240\u6709\u5386\u53F2\u4F1A\u8BDD
5968
+ inspect <id> \u663E\u793A\u4F1A\u8BDD\u8BE6\u60C5
5969
+ replay <id> \u8F93\u51FA\u7528\u4E8E\u56DE\u653E\u7684\u547D\u4EE4
5970
+ fork <id> [turn] \u8F93\u51FA\u7528\u4E8E\u5206\u53C9\u5230\u6307\u5B9A\u8F6E\u6B21\u7684\u547D\u4EE4`;
5971
+ }
5972
+ function renderWebHelp() {
5973
+ return `Usage: ecode web [options]
5974
+
5975
+ Options:
5976
+ -h, --help, -help Show help
5977
+ --host <host> Bind host (default: 127.0.0.1)
5978
+ --port <port> Bind port (default: 4310)`;
5979
+ }
5980
+ if (isHelpFlag(rawArgs[0])) {
5981
+ console.log(renderRootHelp());
5982
+ process.exit(0);
5983
+ }
5984
+ if (rawArgs[0] === "sessions" && (!rawArgs[1] || isHelpFlag(rawArgs[1]))) {
5985
+ console.log(renderSessionsHelp());
5986
+ process.exit(0);
5987
+ }
5988
+ if (rawArgs[0] === "web" && isHelpFlag(rawArgs[1])) {
5989
+ console.log(renderWebHelp());
5990
+ process.exit(0);
5991
+ }
5017
5992
  var autoMode = false;
5018
5993
  var cliLogDir;
5019
5994
  var replayFile;
@@ -5102,61 +6077,103 @@ if (rawArgs[0] === "sessions") {
5102
6077
  console.log(output);
5103
6078
  process.exit(0);
5104
6079
  }
5105
- if (!finalConfig.apiKey) {
5106
- console.error(
5107
- "Error: no API key configured.\nSet ECODE_API_KEY or add apiKey to ~/.ecode/config.json"
5108
- );
5109
- process.exit(1);
5110
- }
5111
- var initialMessages = [];
5112
- if (replayFile) {
5113
- try {
5114
- const raw = readFileSync5(replayFile, "utf-8");
5115
- initialMessages = parseReplayLog(raw);
5116
- } catch (err) {
5117
- console.error(`Error reading replay file: ${err}`);
5118
- process.exit(1);
5119
- }
5120
- } else if (forkSpec) {
5121
- try {
5122
- const raw = readFileSync5(forkSpec.file, "utf-8");
5123
- initialMessages = truncateToTurn(parseReplayLog(raw), forkSpec.turn);
5124
- } catch (err) {
5125
- console.error(`Error reading fork file: ${err}`);
5126
- process.exit(1);
6080
+ if (rawArgs[0] === "web") {
6081
+ let webPort = 4310;
6082
+ let webHost = "127.0.0.1";
6083
+ for (let i = 1; i < rawArgs.length; i++) {
6084
+ const arg = rawArgs[i];
6085
+ const next = rawArgs[i + 1];
6086
+ if (arg === "--port" && next && !next.startsWith("-")) {
6087
+ const parsed = parseInt(next, 10);
6088
+ if (!isNaN(parsed)) webPort = parsed;
6089
+ i++;
6090
+ } else if (arg === "--host" && next && !next.startsWith("-")) {
6091
+ webHost = next;
6092
+ i++;
6093
+ }
5127
6094
  }
5128
- }
5129
- var __dirname = dirname9(fileURLToPath2(import.meta.url));
5130
- var builtinSkillsDir = resolve5(__dirname, "../skills");
5131
- var userSkillsDir = resolve5(process.env.HOME ?? "~", ".ecode/skills");
5132
- var projectSkillsDir = resolve5(process.cwd(), ".ecode/skills");
5133
- var registry = new SkillRegistry();
5134
- for (const dir of [builtinSkillsDir, userSkillsDir, projectSkillsDir]) {
5135
- const skills = await loadSkillsFromDir(dir);
5136
- for (const skill of skills) registry.register(skill);
5137
- }
5138
- var trustedSkillDirs = [builtinSkillsDir, userSkillsDir, projectSkillsDir];
5139
- if (pipeMode) {
5140
- const prompt = await readStdin();
5141
- const llm = createProvider(resolveActiveProfile(finalConfig));
5142
- await runPipe(prompt, llm);
5143
- process.exit(0);
5144
- }
5145
- if (process.stdout.isTTY) {
5146
- process.stdout.write("\x1B[?1049h");
5147
- const exitAltScreen = () => process.stdout.write("\x1B[?1049l");
5148
- process.on("exit", exitAltScreen);
5149
- process.on("SIGINT", () => {
5150
- exitAltScreen();
5151
- process.exit(0);
6095
+ const token = generateAccessToken();
6096
+ const manager = new SessionManager(finalConfig);
6097
+ const server = await buildServer({
6098
+ config: finalConfig,
6099
+ manager,
6100
+ token,
6101
+ version: VERSION
5152
6102
  });
5153
- process.on("SIGTERM", () => {
5154
- exitAltScreen();
6103
+ await server.listen({ port: webPort, host: webHost });
6104
+ const displayHost = webHost === "0.0.0.0" ? "localhost" : webHost;
6105
+ console.log(`
6106
+ ecode web admin started`);
6107
+ console.log(` URL: http://${displayHost}:${webPort}`);
6108
+ console.log(` Token: ${token}`);
6109
+ console.log(`
6110
+ Press Ctrl+C to stop.
6111
+ `);
6112
+ process.on("SIGINT", async () => {
6113
+ await server.close();
5155
6114
  process.exit(0);
5156
6115
  });
5157
- process.on("SIGHUP", () => {
5158
- exitAltScreen();
6116
+ process.on("SIGTERM", async () => {
6117
+ await server.close();
5159
6118
  process.exit(0);
5160
6119
  });
6120
+ } else {
6121
+ if (!finalConfig.apiKey) {
6122
+ console.error(
6123
+ "Error: no API key configured.\nSet ECODE_API_KEY or add apiKey to ~/.ecode/config.json"
6124
+ );
6125
+ process.exit(1);
6126
+ }
6127
+ let initialMessages = [];
6128
+ if (replayFile) {
6129
+ try {
6130
+ const raw = readFileSync6(replayFile, "utf-8");
6131
+ initialMessages = parseReplayLog(raw);
6132
+ } catch (err) {
6133
+ console.error(`Error reading replay file: ${err}`);
6134
+ process.exit(1);
6135
+ }
6136
+ } else if (forkSpec) {
6137
+ try {
6138
+ const raw = readFileSync6(forkSpec.file, "utf-8");
6139
+ initialMessages = truncateToTurn(parseReplayLog(raw), forkSpec.turn);
6140
+ } catch (err) {
6141
+ console.error(`Error reading fork file: ${err}`);
6142
+ process.exit(1);
6143
+ }
6144
+ }
6145
+ const __dirname = dirname9(fileURLToPath2(import.meta.url));
6146
+ const builtinSkillsDir = resolve5(__dirname, "../skills");
6147
+ const userSkillsDir = resolve5(process.env.HOME ?? "~", ".ecode/skills");
6148
+ const projectSkillsDir = resolve5(process.cwd(), ".ecode/skills");
6149
+ const registry = new SkillRegistry();
6150
+ for (const dir of [builtinSkillsDir, userSkillsDir, projectSkillsDir]) {
6151
+ const skills = await loadSkillsFromDir(dir);
6152
+ for (const skill of skills) registry.register(skill);
6153
+ }
6154
+ const trustedSkillDirs = [builtinSkillsDir, userSkillsDir, projectSkillsDir];
6155
+ if (pipeMode) {
6156
+ const prompt = await readStdin();
6157
+ const llm = createProvider(resolveActiveProfile(finalConfig));
6158
+ await runPipe(prompt, llm);
6159
+ process.exit(0);
6160
+ }
6161
+ if (process.stdout.isTTY) {
6162
+ process.stdout.write("\x1B[?1049h");
6163
+ const exitAltScreen = () => process.stdout.write("\x1B[?1049l");
6164
+ process.on("exit", exitAltScreen);
6165
+ process.on("SIGINT", () => {
6166
+ exitAltScreen();
6167
+ process.exit(0);
6168
+ });
6169
+ process.on("SIGTERM", () => {
6170
+ exitAltScreen();
6171
+ process.exit(0);
6172
+ });
6173
+ process.on("SIGHUP", () => {
6174
+ exitAltScreen();
6175
+ process.exit(0);
6176
+ });
6177
+ }
6178
+ render(React4.createElement(App, { config: finalConfig, version: VERSION, autoMode, registry, trustedSkillDirs, initialMessages }));
5161
6179
  }
5162
- render(React4.createElement(App, { config: finalConfig, version: VERSION, autoMode, registry, trustedSkillDirs, initialMessages }));