@turmind/halo 1.3.4-c3090bf → 1.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 (39) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/989-6a0ec35b8ae2cb39.js +1 -0
  3. package/admin-out/_next/static/chunks/app/page-bf9c0b21ae431c92.js +1 -0
  4. package/admin-out/_next/static/css/c0ea31cd787f7aa7.css +3 -0
  5. package/admin-out/index.html +1 -1
  6. package/admin-out/index.txt +5 -5
  7. package/bundled-docs/design/agent.md +12 -6
  8. package/bundled-docs/design/cron.md +19 -11
  9. package/bundled-docs/design/express-self.md +1 -1
  10. package/bundled-docs/design/storage.md +7 -3
  11. package/bundled-docs/design/wechat.md +1 -1
  12. package/bundled-docs/design/ws.md +2 -1
  13. package/bundled-docs/dev/add-model-provider.md +1 -1
  14. package/bundled-docs/dev/api.md +6 -6
  15. package/bundled-docs/dev/deploy.md +1 -1
  16. package/bundled-docs/dev/desktop-packaging.md +1 -1
  17. package/bundled-docs/dev/env.md +4 -4
  18. package/bundled-docs/dev/tools.md +39 -17
  19. package/bundled-docs/guide/chat.md +10 -0
  20. package/bundled-docs/guide/cli.md +8 -3
  21. package/bundled-docs/requirements/chat.md +8 -0
  22. package/dist/index.js +498 -68
  23. package/package.json +1 -1
  24. package/templates/agents/__apply_agent__/agent.yaml +1 -1
  25. package/templates/agents/__evo_agent__/agent.yaml +1 -1
  26. package/templates/agents/__score__/agent.yaml +1 -1
  27. package/templates/agents/deep-executor/agent.yaml +1 -1
  28. package/templates/agents/default/agent.yaml +1 -1
  29. package/templates/agents/goal/agent.yaml +1 -1
  30. package/templates/models/mimo-token-plan-china.yaml +13 -6
  31. package/templates/skills/agent/SKILL.md +2 -2
  32. package/templates/skills/cron/SKILL.md +15 -0
  33. package/templates/skills/cron/manage-cron.py +28 -3
  34. package/templates/skills/send-file/SKILL.md +1 -0
  35. package/admin-out/_next/static/chunks/989-e660d0dd799950f2.js +0 -1
  36. package/admin-out/_next/static/chunks/app/page-18ab83dbdcc3897c.js +0 -1
  37. package/admin-out/_next/static/css/59402ac06b2b1a10.css +0 -3
  38. /package/admin-out/_next/static/{BVMDWdlRNum62_KgIRZVc → 9_eFiojb_-MS8CJ2oJg8_}/_buildManifest.js +0 -0
  39. /package/admin-out/_next/static/{BVMDWdlRNum62_KgIRZVc → 9_eFiojb_-MS8CJ2oJg8_}/_ssgManifest.js +0 -0
package/dist/index.js CHANGED
@@ -513,12 +513,12 @@ function generalSection() {
513
513
  { key: "compact.max_summary_input", type: "int", globalOnly: true, description: "Max chars fed into local truncation fallback", description_zh: "\u672C\u5730\u622A\u65AD\u515C\u5E95\u65F6\u7684\u603B\u8F93\u5165\u5B57\u7B26\u4E0A\u9650", default: "15000" },
514
514
  { key: "compact.max_message_slice", type: "int", globalOnly: true, description: "Max chars kept per old message during local truncation", description_zh: "\u672C\u5730\u622A\u65AD\u515C\u5E95\u65F6\u6BCF\u6761\u65E7\u6D88\u606F\u4FDD\u7559\u7684\u6700\u5927\u5B57\u7B26\u6570", default: "800" },
515
515
  { key: "compact.summarize_timeout_sec", type: "int", globalOnly: true, description: "LLM summarization timeout (seconds)", description_zh: "LLM \u6458\u8981\u8D85\u65F6\u65F6\u95F4\uFF08\u79D2\uFF09", default: "300" },
516
- // sandbox (Linux bwrap only) — global-only: these define the security
516
+ // sandbox (Linux bwrap / macOS sandbox-exec) — global-only: these define the security
517
517
  // boundary agents run inside; a workspace overriding them could lift
518
518
  // its own sandbox constraints.
519
- { key: "sandbox.hidden_dirs", globalOnly: true, description: "Comma-separated dirs hidden by bwrap (Linux only)", description_zh: "\u901A\u8FC7 bwrap tmpfs \u9690\u85CF\u7684\u76EE\u5F55\uFF08\u9017\u53F7\u5206\u9694\uFF0C\u4EC5 Linux\uFF09", default: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs" },
520
- { key: "sandbox.writable_dirs", globalOnly: true, description: "Comma-separated dirs bind-mounted read-write inside the bwrap sandbox (Linux only) \u2014 for external CLIs that keep local state, e.g. ~/.kiro,~/.local/share/kiro-cli. Not applied to readonly sessions.", description_zh: "\u5728 bwrap \u6C99\u7BB1\u5185\u4EE5\u53EF\u5199\u65B9\u5F0F\u6302\u8F7D\u7684\u76EE\u5F55\uFF08\u9017\u53F7\u5206\u9694\uFF0C\u4EC5 Linux\uFF09\u2014\u2014\u7ED9\u9700\u8981\u672C\u5730\u72B6\u6001\u7684\u5916\u90E8 CLI \u7528\uFF0C\u5982 ~/.kiro,~/.local/share/kiro-cli\u3002readonly \u4F1A\u8BDD\u4E0D\u751F\u6548\u3002", default: "" },
521
- { key: "sandbox.hidden_files", globalOnly: true, description: "Comma-separated files hidden by bwrap (Linux only)", description_zh: "\u901A\u8FC7 /dev/null bind \u9690\u85CF\u7684\u6587\u4EF6\uFF08\u9017\u53F7\u5206\u9694\uFF0C\u4EC5 Linux\uFF09", default: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm" },
519
+ { key: "sandbox.hidden_dirs", globalOnly: true, description: "Comma-separated dirs hidden from workspace/readonly sessions (Linux bwrap / macOS sandbox-exec)", description_zh: "\u5BF9 workspace/readonly \u4F1A\u8BDD\u9690\u85CF\u7684\u76EE\u5F55\uFF08\u9017\u53F7\u5206\u9694\uFF0CLinux bwrap / macOS sandbox-exec\uFF09", default: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs" },
520
+ { key: "sandbox.writable_dirs", globalOnly: true, description: "Comma-separated dirs writable inside the sandbox besides the workspace (Linux / macOS) \u2014 for external CLIs that keep local state, e.g. ~/.kiro,~/.local/share/kiro-cli. Not applied to readonly sessions.", description_zh: "\u9664 workspace \u5916\u5728\u6C99\u7BB1\u5185\u53EF\u5199\u7684\u76EE\u5F55\uFF08\u9017\u53F7\u5206\u9694\uFF0CLinux / macOS\uFF09\u2014\u2014\u7ED9\u9700\u8981\u672C\u5730\u72B6\u6001\u7684\u5916\u90E8 CLI \u7528\uFF0C\u5982 ~/.kiro,~/.local/share/kiro-cli\u3002readonly \u4F1A\u8BDD\u4E0D\u751F\u6548\u3002", default: "" },
521
+ { key: "sandbox.hidden_files", globalOnly: true, description: "Comma-separated files hidden from workspace/readonly sessions \u2014 read as empty (Linux / macOS)", description_zh: "\u5BF9 workspace/readonly \u4F1A\u8BDD\u9690\u85CF\u7684\u6587\u4EF6\uFF0C\u8BFB\u5230\u7684\u662F\u7A7A\u6587\u4EF6\uFF08\u9017\u53F7\u5206\u9694\uFF0CLinux / macOS\uFF09", default: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm" },
522
522
  // logging
523
523
  { key: "logging.level", type: "enum", options: ["debug", "info", "warn", "error"], globalOnly: true, description: "Log level", description_zh: "\u65E5\u5FD7\u7EA7\u522B", default: "warn" },
524
524
  // observability (OpenTelemetry). Read once at boot by observability/otel.ts
@@ -2130,6 +2130,7 @@ __export(init_exports, {
2130
2130
  describeOptionalSkill: () => describeOptionalSkill,
2131
2131
  ensureHaloHome: () => ensureHaloHome,
2132
2132
  ensureWorkspaceHalo: () => ensureWorkspaceHalo,
2133
+ hasWorkspaceHalo: () => hasWorkspaceHalo,
2133
2134
  listOptionalSkillIds: () => listOptionalSkillIds,
2134
2135
  mergeAgentYaml: () => mergeAgentYaml,
2135
2136
  readInstalledOptionalSkills: () => readInstalledOptionalSkills,
@@ -2458,6 +2459,9 @@ function ensureHaloHome(haloHome2) {
2458
2459
  } catch {
2459
2460
  }
2460
2461
  }
2462
+ function hasWorkspaceHalo(root) {
2463
+ return fs7.existsSync(path8.join(root, ".halo"));
2464
+ }
2461
2465
  function ensureWorkspaceHalo(workspaceRoot) {
2462
2466
  if (!fs7.existsSync(workspaceRoot)) {
2463
2467
  throw new Error(`Workspace root does not exist: ${workspaceRoot}`);
@@ -2497,7 +2501,7 @@ var init_init = __esm({
2497
2501
  path8.resolve(__dirname, "..", "bundled-docs"),
2498
2502
  path8.resolve(__dirname, "..", "..", "..", ".halo", "docs")
2499
2503
  ];
2500
- TEMPLATE_VERSION = 60;
2504
+ TEMPLATE_VERSION = 65;
2501
2505
  VERSION_FILE = ".template-version";
2502
2506
  SKIP_NAMES = /* @__PURE__ */ new Set([".DS_Store", "schema.sql", "__pycache__", ".pytest_cache"]);
2503
2507
  BUILTIN_AGENT_IDS = /* @__PURE__ */ new Set([
@@ -9491,7 +9495,7 @@ import { exec, execFile, spawn as spawn2 } from "node:child_process";
9491
9495
  import { promisify } from "node:util";
9492
9496
  import path25 from "node:path";
9493
9497
  import { homedir as homedir10 } from "node:os";
9494
- import { existsSync, realpathSync as realpathSync2 } from "node:fs";
9498
+ import { existsSync, lstatSync, mkdirSync, realpathSync as realpathSync2, rmSync, writeFileSync } from "node:fs";
9495
9499
  function decodeWinOutput(buf) {
9496
9500
  try {
9497
9501
  return new TextDecoder("utf-8", { fatal: true }).decode(buf);
@@ -9525,7 +9529,7 @@ async function bwrapExec(args, opts) {
9525
9529
  }
9526
9530
  function spawnGroupExec(command, opts) {
9527
9531
  return new Promise((resolve2, reject) => {
9528
- const child = spawn2(command, { shell: true, detached: true, cwd: opts.cwd, env: cleanChildEnv() });
9532
+ const child = opts.argv ? spawn2(opts.argv[0], opts.argv.slice(1), { detached: true, cwd: opts.cwd, env: opts.env ?? cleanChildEnv() }) : spawn2(command, { shell: true, detached: true, cwd: opts.cwd, env: cleanChildEnv() });
9529
9533
  let stdout = "";
9530
9534
  let stderr = "";
9531
9535
  let killReason = null;
@@ -9637,12 +9641,68 @@ async function isBwrapAvailable() {
9637
9641
  return false;
9638
9642
  }
9639
9643
  }
9640
- function isBwrapCached() {
9641
- return _bwrapAvailable === true;
9644
+ async function isSeatbeltAvailable() {
9645
+ if (process.platform !== "darwin")
9646
+ return false;
9647
+ if (_seatbeltAvailable !== null)
9648
+ return _seatbeltAvailable;
9649
+ try {
9650
+ await execFileAsync(SANDBOX_EXEC, ["-p", "(version 1)(allow default)", "/usr/bin/true"], { timeout: 5e3 });
9651
+ _seatbeltAvailable = true;
9652
+ } catch (err) {
9653
+ console.warn(`[Sandbox] sandbox-exec probe failed (${String(err.message).split("\n")[0]}) \u2014 non-full shell_exec unavailable`);
9654
+ _seatbeltAvailable = false;
9655
+ }
9656
+ return _seatbeltAvailable;
9657
+ }
9658
+ function getSandboxBackend() {
9659
+ if (_bwrapAvailable === true)
9660
+ return "bwrap";
9661
+ if (_seatbeltAvailable === true)
9662
+ return "seatbelt";
9663
+ return null;
9664
+ }
9665
+ async function loadGitIdentity() {
9666
+ const read = async (key) => {
9667
+ try {
9668
+ return String((await execFileAsync("git", ["config", "--global", key], { timeout: 3e3 })).stdout).trim();
9669
+ } catch {
9670
+ return "";
9671
+ }
9672
+ };
9673
+ const [name, email] = await Promise.all([read("user.name"), read("user.email")]);
9674
+ _gitIdentity = { name, email };
9675
+ }
9676
+ function gitIdentityEnv() {
9677
+ const env = {};
9678
+ if (_gitIdentity.name) {
9679
+ env.GIT_AUTHOR_NAME = _gitIdentity.name;
9680
+ env.GIT_COMMITTER_NAME = _gitIdentity.name;
9681
+ }
9682
+ if (_gitIdentity.email) {
9683
+ env.GIT_AUTHOR_EMAIL = _gitIdentity.email;
9684
+ env.GIT_COMMITTER_EMAIL = _gitIdentity.email;
9685
+ }
9686
+ return env;
9642
9687
  }
9643
9688
  async function initBwrapCheck() {
9689
+ await loadGitIdentity();
9690
+ if (process.platform === "darwin")
9691
+ return isSeatbeltAvailable();
9644
9692
  return isBwrapAvailable();
9645
9693
  }
9694
+ function ensureEmptyMaskFile() {
9695
+ try {
9696
+ const st = lstatSync(EMPTY_MASK_FILE);
9697
+ if (st.isFile() && st.size === 0)
9698
+ return EMPTY_MASK_FILE;
9699
+ rmSync(EMPTY_MASK_FILE, { recursive: true, force: true });
9700
+ } catch {
9701
+ }
9702
+ mkdirSync(HALO_HOME3, { recursive: true });
9703
+ writeFileSync(EMPTY_MASK_FILE, "");
9704
+ return EMPTY_MASK_FILE;
9705
+ }
9646
9706
  function expandTilde(p) {
9647
9707
  return p.startsWith("~/") ? path25.join(HOME, p.slice(2)) : p;
9648
9708
  }
@@ -9650,6 +9710,20 @@ function setSandboxHiddenPaths(dirs, files, writableDirs = []) {
9650
9710
  _hiddenDirs = dirs;
9651
9711
  _hiddenFiles = files;
9652
9712
  _writableDirs = writableDirs;
9713
+ _resolvedLists = null;
9714
+ }
9715
+ function resolvedLists() {
9716
+ if (_resolvedLists)
9717
+ return _resolvedLists;
9718
+ const both = (raws) => [...new Set(raws.flatMap((raw) => {
9719
+ const p = path25.resolve(expandTilde(raw));
9720
+ return [p, realpathBounded(p)];
9721
+ }))];
9722
+ _resolvedLists = { hiddenDirs: both(_hiddenDirs), hiddenFiles: both(_hiddenFiles), writableDirs: both(_writableDirs) };
9723
+ return _resolvedLists;
9724
+ }
9725
+ function isUnder(p, dir) {
9726
+ return p === dir || p.startsWith(dir.endsWith("/") ? dir : dir + "/");
9653
9727
  }
9654
9728
  function buildBwrapArgs(opts) {
9655
9729
  const args = [];
@@ -9660,10 +9734,11 @@ function buildBwrapArgs(opts) {
9660
9734
  if (existsSync(dir))
9661
9735
  args.push("--tmpfs", dir);
9662
9736
  }
9737
+ const emptyFile = ensureEmptyMaskFile();
9663
9738
  for (const raw of _hiddenFiles) {
9664
9739
  const file = expandTilde(raw);
9665
9740
  if (existsSync(file))
9666
- args.push("--ro-bind", "/dev/null", file);
9741
+ args.push("--ro-bind", emptyFile, file);
9667
9742
  }
9668
9743
  if (opts.accessLevel !== "readonly") {
9669
9744
  args.push("--bind", opts.workspaceRoot, opts.workspaceRoot);
@@ -9683,7 +9758,7 @@ function buildBwrapArgs(opts) {
9683
9758
  for (const rel of WORKSPACE_HIDDEN_FILES) {
9684
9759
  const file = path25.join(opts.workspaceRoot, rel);
9685
9760
  if (existsSync(file))
9686
- args.push("--ro-bind", "/dev/null", file);
9761
+ args.push("--ro-bind", emptyFile, file);
9687
9762
  }
9688
9763
  args.push("--proc", "/proc");
9689
9764
  args.push("--dev", "/dev");
@@ -9691,6 +9766,8 @@ function buildBwrapArgs(opts) {
9691
9766
  args.push("--setenv", "PATH", `${HOME}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`);
9692
9767
  args.push("--setenv", "HOME", HOME);
9693
9768
  args.push("--setenv", "TERM", "xterm-256color");
9769
+ for (const [k, v] of Object.entries(gitIdentityEnv()))
9770
+ args.push("--setenv", k, v);
9694
9771
  args.push("--die-with-parent");
9695
9772
  return args;
9696
9773
  }
@@ -9718,31 +9795,57 @@ function assertPathAllowed(filePath, opts, write = false) {
9718
9795
  return path25.resolve(filePath);
9719
9796
  const resolved = realpathBounded(filePath);
9720
9797
  const wsRoot = realpathBounded(opts.workspaceRoot);
9721
- if (resolved === wsRoot || resolved.startsWith(wsRoot + "/")) {
9722
- if (isHiddenWorkspacePath(resolved, wsRoot)) {
9723
- throw new Error(`Access denied: "${filePath}" is outside the allowed sandbox paths`);
9724
- }
9725
- if (write && opts.accessLevel === "readonly") {
9726
- throw new Error(`Access denied: readonly session cannot write to "${filePath}"`);
9727
- }
9728
- return resolved;
9798
+ const inWorkspace = isUnder(resolved, wsRoot);
9799
+ if (inWorkspace && isHiddenWorkspacePath(resolved, wsRoot) || isHiddenHostPath(resolved)) {
9800
+ throw new Error(`Access denied: "${filePath}" is outside the allowed sandbox paths`);
9729
9801
  }
9730
- if (!write && (resolved === GLOBAL_DIR || resolved.startsWith(GLOBAL_DIR + "/")) && !isHiddenGlobalPath(resolved)) {
9802
+ if (!write)
9731
9803
  return resolved;
9804
+ if (opts.accessLevel === "readonly") {
9805
+ throw new Error(`Access denied: readonly session cannot write to "${filePath}"`);
9732
9806
  }
9807
+ if (inWorkspace || resolvedLists().writableDirs.some((d) => isUnder(resolved, d)))
9808
+ return resolved;
9733
9809
  throw new Error(`Access denied: "${filePath}" is outside the allowed sandbox paths`);
9734
9810
  }
9735
- function isHiddenGlobalPath(resolved) {
9736
- for (const raw of _hiddenFiles) {
9737
- if (resolved === expandTilde(raw))
9738
- return true;
9739
- }
9740
- for (const raw of _hiddenDirs) {
9741
- const dir = expandTilde(raw);
9742
- if (resolved === dir || resolved.startsWith(dir + "/"))
9743
- return true;
9811
+ function isHiddenHostPath(resolved) {
9812
+ const { hiddenDirs, hiddenFiles } = resolvedLists();
9813
+ return hiddenFiles.includes(resolved) || hiddenDirs.some((d) => isUnder(resolved, d));
9814
+ }
9815
+ function buildSeatbeltProfile(opts) {
9816
+ const q = (p) => `"${p.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
9817
+ const wsRoot = realpathBounded(opts.workspaceRoot);
9818
+ const lists = resolvedLists();
9819
+ const writable = ["/private/tmp", "/private/var/folders", "/dev"];
9820
+ if (opts.accessLevel !== "readonly")
9821
+ writable.unshift(wsRoot, ...lists.writableDirs);
9822
+ const hiddenDirs = [...lists.hiddenDirs, ...WORKSPACE_HIDDEN_DIRS.map((rel) => path25.join(wsRoot, rel))];
9823
+ const hiddenFiles = [...lists.hiddenFiles, ...WORKSPACE_HIDDEN_FILES.map((rel) => path25.join(wsRoot, rel))];
9824
+ return [
9825
+ "(version 1)",
9826
+ "(allow default)",
9827
+ "(deny file-write*)",
9828
+ `(allow file-write* ${writable.map((p) => `(subpath ${q(p)})`).join(" ")})`,
9829
+ `(deny file-read* file-write* ${[
9830
+ ...hiddenDirs.map((p) => `(subpath ${q(p)})`),
9831
+ ...hiddenFiles.map((p) => `(literal ${q(p)})`)
9832
+ ].join(" ")})`
9833
+ ].join("\n");
9834
+ }
9835
+ function seatbeltEnv() {
9836
+ const env = {
9837
+ PATH: `${HOME}/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin`,
9838
+ HOME,
9839
+ TERM: "xterm-256color",
9840
+ GIT_CONFIG_GLOBAL: "/dev/null",
9841
+ NPM_CONFIG_USERCONFIG: "/dev/null",
9842
+ ...gitIdentityEnv()
9843
+ };
9844
+ for (const k of ["TMPDIR", "USER", "LOGNAME", "LANG", "LC_ALL"]) {
9845
+ if (process.env[k])
9846
+ env[k] = process.env[k];
9744
9847
  }
9745
- return false;
9848
+ return env;
9746
9849
  }
9747
9850
  function isHiddenWorkspacePath(resolved, wsRoot) {
9748
9851
  for (const rel of WORKSPACE_HIDDEN_FILES) {
@@ -9766,8 +9869,173 @@ function buildWriteScriptArgs(filePath, content) {
9766
9869
  function buildReaddirScriptArgs(dirPath) {
9767
9870
  return ["bash", "-c", 'ls -1ap "$1"', "bash", dirPath];
9768
9871
  }
9872
+ function shellSegments(command) {
9873
+ const segments = [];
9874
+ let words = [];
9875
+ let cur = "";
9876
+ let inWord = false;
9877
+ let quote = null;
9878
+ const heredocs = [];
9879
+ const endWord = () => {
9880
+ if (inWord)
9881
+ words.push(cur);
9882
+ cur = "";
9883
+ inWord = false;
9884
+ };
9885
+ const endSegment = () => {
9886
+ endWord();
9887
+ if (words.length)
9888
+ segments.push(words);
9889
+ words = [];
9890
+ };
9891
+ for (let i = 0; i < command.length; i++) {
9892
+ const c = command[i];
9893
+ if (quote) {
9894
+ if (c === quote)
9895
+ quote = null;
9896
+ else if (c === "\\" && quote === '"' && i + 1 < command.length)
9897
+ cur += command[++i];
9898
+ else
9899
+ cur += c;
9900
+ continue;
9901
+ }
9902
+ if (c === "<" && command[i + 1] === "<" && command[i + 2] !== "<") {
9903
+ const m = /^<<(-?)\s*(['"]?)([A-Za-z0-9_.-]+)\2/.exec(command.slice(i));
9904
+ if (m) {
9905
+ endWord();
9906
+ heredocs.push({ delim: m[3], dash: m[1] === "-" });
9907
+ i += m[0].length - 1;
9908
+ continue;
9909
+ }
9910
+ }
9911
+ if (c === "\n" && heredocs.length) {
9912
+ endSegment();
9913
+ let pos = i + 1;
9914
+ for (const h of heredocs.splice(0)) {
9915
+ while (pos < command.length) {
9916
+ const eol = command.indexOf("\n", pos);
9917
+ const line = command.slice(pos, eol === -1 ? command.length : eol);
9918
+ pos = eol === -1 ? command.length : eol + 1;
9919
+ if ((h.dash ? line.replace(/^\t+/, "") : line) === h.delim)
9920
+ break;
9921
+ }
9922
+ }
9923
+ i = pos - 1;
9924
+ continue;
9925
+ }
9926
+ if (c === '"' || c === "'") {
9927
+ quote = c;
9928
+ inWord = true;
9929
+ } else if (c === "\\" && command[i + 1] === "\n")
9930
+ i++;
9931
+ else if (c === "\\" && i + 1 < command.length) {
9932
+ cur += command[++i];
9933
+ inWord = true;
9934
+ } else if (c === ";" || c === "&" || c === "|" || c === "\n")
9935
+ endSegment();
9936
+ else if (c === " " || c === " " || c === "\r")
9937
+ endWord();
9938
+ else {
9939
+ cur += c;
9940
+ inWord = true;
9941
+ }
9942
+ }
9943
+ endSegment();
9944
+ return segments;
9945
+ }
9946
+ function expandRmWord(word, cwd) {
9947
+ return word.replace(/^~(?=\/|$)/, HOME).replace(/\$\{HOME\}|\$HOME\b/g, HOME).replace(/\$\{PWD\}|\$PWD\b|\$\(pwd\)|`pwd`/g, cwd).replace(/\$\{[^}]*\}|\$\([^)]*\)|`[^`]*`|\$[A-Za-z_][A-Za-z0-9_]*|\$\d/g, "");
9948
+ }
9949
+ function stripSubshellClose(word) {
9950
+ let w = word;
9951
+ while (w.endsWith(")") && (w.match(/\(/g)?.length ?? 0) < (w.match(/\)/g)?.length ?? 0))
9952
+ w = w.slice(0, -1);
9953
+ return w;
9954
+ }
9955
+ function rmProtectedReason(target, wsRoot) {
9956
+ if (target === "/")
9957
+ return "the filesystem root";
9958
+ if (isUnder(HOME, target))
9959
+ return "the home directory or a parent of it";
9960
+ if (isUnder(HALO_HOME3, target))
9961
+ return "~/.halo or a parent of it";
9962
+ if (isUnder(wsRoot, target))
9963
+ return "the workspace root or a parent of it";
9964
+ if (RM_EXACT_DIRS.includes(target))
9965
+ return "a system directory";
9966
+ for (const d of RM_SYSTEM_DIRS) {
9967
+ if (target === d || path25.dirname(target) === d)
9968
+ return "a system directory";
9969
+ }
9970
+ return null;
9971
+ }
9972
+ function assertRmSafe(command, workspaceRoot) {
9973
+ const wsRoot = path25.resolve(workspaceRoot);
9974
+ let cwd = wsRoot;
9975
+ for (const words of shellSegments(command)) {
9976
+ let i = 0;
9977
+ let cmd = "";
9978
+ for (; i < words.length; i++) {
9979
+ cmd = words[i].replace(/^[({!]+/, "");
9980
+ if (!cmd || /^[A-Za-z_][A-Za-z0-9_]*=/.test(cmd))
9981
+ continue;
9982
+ if (RM_WRAPPERS.has(path25.basename(cmd))) {
9983
+ while (i + 1 < words.length && words[i + 1].startsWith("-"))
9984
+ i++;
9985
+ continue;
9986
+ }
9987
+ break;
9988
+ }
9989
+ if (i >= words.length)
9990
+ continue;
9991
+ if (cmd === "cd") {
9992
+ const dest = words[i + 1];
9993
+ if (dest === void 0)
9994
+ cwd = HOME;
9995
+ else if (dest !== "-")
9996
+ cwd = path25.resolve(cwd, expandRmWord(stripSubshellClose(dest), cwd) || ".");
9997
+ continue;
9998
+ }
9999
+ const base = path25.basename(cmd);
10000
+ if (base !== "rm" && base !== "rmdir")
10001
+ continue;
10002
+ let optionsDone = false;
10003
+ for (let j = i + 1; j < words.length; j++) {
10004
+ const raw = words[j];
10005
+ if (!optionsDone && raw === "--") {
10006
+ optionsDone = true;
10007
+ continue;
10008
+ }
10009
+ if (!optionsDone && raw.startsWith("-"))
10010
+ continue;
10011
+ if (/^(\d*|&)?[<>]/.test(raw)) {
10012
+ if (/^(\d*|&)?[<>]+&?$/.test(raw))
10013
+ j++;
10014
+ continue;
10015
+ }
10016
+ const word = expandRmWord(stripSubshellClose(raw), cwd);
10017
+ if (!word)
10018
+ continue;
10019
+ const abs = path25.resolve(cwd, word);
10020
+ const parts = abs.split("/");
10021
+ const globAt = parts.findIndex((p) => /[*?[]/.test(p));
10022
+ let target = abs;
10023
+ if (globAt !== -1) {
10024
+ if (!/^[.*?]+$/.test(parts[globAt]))
10025
+ continue;
10026
+ target = parts.slice(0, globAt).join("/") || "/";
10027
+ }
10028
+ const reason = rmProtectedReason(target, wsRoot);
10029
+ if (reason) {
10030
+ throw new Error(`[Sandbox] rm blocked: "${raw}" resolves to ${target}, which is ${reason}. Name the specific files or subdirectories to delete instead.`);
10031
+ }
10032
+ }
10033
+ }
10034
+ }
9769
10035
  async function sandboxExec(command, opts) {
9770
10036
  opts = normalizeOptsForPlatform(opts);
10037
+ if (process.platform !== "win32")
10038
+ assertRmSafe(command, opts.workspaceRoot);
9771
10039
  if (opts.accessLevel === "full") {
9772
10040
  if (process.platform === "win32") {
9773
10041
  const result = await execAsync(`chcp 65001 >nul 2>&1 & ${command}`, {
@@ -9789,10 +10057,21 @@ async function sandboxExec(command, opts) {
9789
10057
  signal: opts.signal
9790
10058
  });
9791
10059
  }
9792
- const bwrapOk = await isBwrapAvailable();
9793
- if (!bwrapOk) {
9794
- throw new Error("Access denied: shell_exec requires bubblewrap (bwrap) for non-full access levels. Install with: apt install bubblewrap");
10060
+ if (process.platform === "darwin") {
10061
+ if (!await isSeatbeltAvailable())
10062
+ throw new Error(NO_SANDBOX_MSG);
10063
+ return spawnGroupExec(command, {
10064
+ cwd: opts.workspaceRoot,
10065
+ timeout: opts.timeout,
10066
+ maxBuffer: opts.maxBuffer,
10067
+ signal: opts.signal,
10068
+ argv: [SANDBOX_EXEC, "-p", buildSeatbeltProfile(opts), ...buildExecScriptArgs(opts.workspaceRoot, command)],
10069
+ env: seatbeltEnv()
10070
+ });
9795
10071
  }
10072
+ const bwrapOk = await isBwrapAvailable();
10073
+ if (!bwrapOk)
10074
+ throw new Error(NO_SANDBOX_MSG);
9796
10075
  const bwrapArgs = buildBwrapArgs(opts);
9797
10076
  return bwrapExec([...bwrapArgs, "--", ...buildExecScriptArgs(opts.workspaceRoot, command)], {
9798
10077
  timeout: opts.timeout,
@@ -9959,7 +10238,7 @@ async function sandboxReaddir(dirPath, opts) {
9959
10238
  return { name: isDir ? name.slice(0, -1) : name, isDirectory: isDir };
9960
10239
  });
9961
10240
  }
9962
- var execAsync, execFileAsync, HOME, HALO_HOME3, GLOBAL_DIR, KILL_GRACE_MS, _bwrapAvailable, DEFAULT_HIDDEN_DIRS, DEFAULT_HIDDEN_FILES, WORKSPACE_HIDDEN_DIRS, WORKSPACE_HIDDEN_FILES, _hiddenDirs, _hiddenFiles, _writableDirs;
10241
+ var execAsync, execFileAsync, HOME, HALO_HOME3, KILL_GRACE_MS, _bwrapAvailable, SANDBOX_EXEC, _seatbeltAvailable, _gitIdentity, EMPTY_MASK_FILE, DEFAULT_HIDDEN_DIRS, DEFAULT_HIDDEN_FILES, WORKSPACE_HIDDEN_DIRS, WORKSPACE_HIDDEN_FILES, _hiddenDirs, _hiddenFiles, _writableDirs, _resolvedLists, RM_SYSTEM_DIRS, RM_EXACT_DIRS, RM_WRAPPERS, NO_SANDBOX_MSG;
9963
10242
  var init_sandbox = __esm({
9964
10243
  "../server/dist/tools/sandbox.js"() {
9965
10244
  "use strict";
@@ -9968,9 +10247,12 @@ var init_sandbox = __esm({
9968
10247
  execFileAsync = promisify(execFile);
9969
10248
  HOME = homedir10();
9970
10249
  HALO_HOME3 = path25.join(HOME, ".halo");
9971
- GLOBAL_DIR = path25.join(HALO_HOME3, "global");
9972
10250
  KILL_GRACE_MS = 2e3;
9973
10251
  _bwrapAvailable = null;
10252
+ SANDBOX_EXEC = "/usr/bin/sandbox-exec";
10253
+ _seatbeltAvailable = null;
10254
+ _gitIdentity = { name: "", email: "" };
10255
+ EMPTY_MASK_FILE = path25.join(HALO_HOME3, ".sandbox-empty");
9974
10256
  DEFAULT_HIDDEN_DIRS = [
9975
10257
  "~/.halo/secrets",
9976
10258
  "~/.aws",
@@ -10011,6 +10293,38 @@ var init_sandbox = __esm({
10011
10293
  _hiddenDirs = DEFAULT_HIDDEN_DIRS;
10012
10294
  _hiddenFiles = DEFAULT_HIDDEN_FILES;
10013
10295
  _writableDirs = [];
10296
+ _resolvedLists = null;
10297
+ RM_SYSTEM_DIRS = [
10298
+ "/bin",
10299
+ "/boot",
10300
+ "/dev",
10301
+ "/etc",
10302
+ "/home",
10303
+ "/lib",
10304
+ "/lib32",
10305
+ "/lib64",
10306
+ "/libx32",
10307
+ "/media",
10308
+ "/mnt",
10309
+ "/opt",
10310
+ "/proc",
10311
+ "/run",
10312
+ "/sbin",
10313
+ "/snap",
10314
+ "/srv",
10315
+ "/sys",
10316
+ "/usr",
10317
+ "/var",
10318
+ "/Applications",
10319
+ "/Library",
10320
+ "/System",
10321
+ "/Users",
10322
+ "/Volumes",
10323
+ "/private"
10324
+ ];
10325
+ RM_EXACT_DIRS = ["/tmp", "/private/tmp", "/root"];
10326
+ RM_WRAPPERS = /* @__PURE__ */ new Set(["sudo", "command", "env", "xargs", "nohup", "time", "exec", "nice", "if", "then", "else", "elif", "do", "while", "until"]);
10327
+ NO_SANDBOX_MSG = "Access denied: shell_exec at workspace/readonly access needs an OS sandbox (bubblewrap on Linux, sandbox-exec on macOS) and none works on this host. Switch the session to Full, or on Linux install bubblewrap (apt install bubblewrap).";
10014
10328
  }
10015
10329
  });
10016
10330
 
@@ -10530,10 +10844,13 @@ Error: old_string appears more than once in ${input.path}. Either expand it with
10530
10844
  STDERR: ${stderr}` : "")).trim() || "(no output)");
10531
10845
  } catch (err) {
10532
10846
  const error = err;
10533
- return mask(`${TOOL_ERROR_MARKER}
10534
- Command failed: ${error.message}
10847
+ const out = `${error.message}
10535
10848
  ${error.stdout ?? ""}
10536
- ${error.stderr ?? ""}`);
10849
+ ${error.stderr ?? ""}`;
10850
+ const hint = accessLevel !== "full" && SANDBOX_DENIED_RE.test(out) ? `
10851
+ [Sandbox] This session runs at "${accessLevel}" access: writes are limited to the workspace${accessLevel === "readonly" ? " (readonly: nowhere)" : ""} and /tmp is discarded after each command. If the command needs more, ask the user to switch the access level to Full in the chat input box.` : "";
10852
+ return mask(`${TOOL_ERROR_MARKER}
10853
+ Command failed: ${out}${hint}`);
10537
10854
  }
10538
10855
  }
10539
10856
  };
@@ -10698,7 +11015,7 @@ Error: ${error.message}`;
10698
11015
  };
10699
11016
  const visionTools = supportsVision ? [viewImage] : [];
10700
11017
  const allTools = [fileRead, ...visionTools, fileWrite, fileEdit, fileList, shellExec, grepTool, globTool, webFetch];
10701
- if (accessLevel === "readonly" && !isBwrapCached()) {
11018
+ if (accessLevel === "readonly" && getSandboxBackend() === null) {
10702
11019
  return [fileRead, ...visionTools, fileList, grepTool, globTool];
10703
11020
  }
10704
11021
  return allTools;
@@ -10731,7 +11048,7 @@ function imageDimensions(buf, mediaType) {
10731
11048
  }
10732
11049
  return null;
10733
11050
  }
10734
- var SKIP_DIRS, SKIP_HALO_SUBDIRS, GLOB_MAX_RESULTS, HOME2, PARAMS_PATTERN, ENV_PATTERN3;
11051
+ var SKIP_DIRS, SKIP_HALO_SUBDIRS, GLOB_MAX_RESULTS, SANDBOX_DENIED_RE, HOME2, PARAMS_PATTERN, ENV_PATTERN3;
10735
11052
  var init_workspace_tools = __esm({
10736
11053
  "../server/dist/tools/workspace-tools.js"() {
10737
11054
  "use strict";
@@ -10744,6 +11061,7 @@ var init_workspace_tools = __esm({
10744
11061
  SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".next", "dist"]);
10745
11062
  SKIP_HALO_SUBDIRS = /* @__PURE__ */ new Set(["sessions", "logs", "evo", "tmp", "assets", "canvas"]);
10746
11063
  GLOB_MAX_RESULTS = 5e3;
11064
+ SANDBOX_DENIED_RE = /Read-only file system|Operation not permitted|Permission denied|EROFS|EPERM/;
10747
11065
  HOME2 = homedir11();
10748
11066
  PARAMS_PATTERN = /\{\{\s*([\w-]+)\.params\.([\w-][\w.-]*)\s*\}\}/g;
10749
11067
  ENV_PATTERN3 = /<<([A-Z_][A-Z0-9_]*)>>/g;
@@ -11487,7 +11805,7 @@ async function initObservability() {
11487
11805
  return true;
11488
11806
  }
11489
11807
  const { registerSdk: registerSdk2 } = await Promise.resolve().then(() => (init_otel_sdk(), otel_sdk_exports));
11490
- providers = registerSdk2(process.env.OTEL_SERVICE_NAME, "1.3.4-c3090bf");
11808
+ providers = registerSdk2(process.env.OTEL_SERVICE_NAME, "1.4.0");
11491
11809
  console.log(`[Observability] OTLP export \u2192 ${process.env.OTEL_EXPORTER_OTLP_ENDPOINT} (service.name=${process.env.OTEL_SERVICE_NAME}, capture_content=${config.observability.captureContent})`);
11492
11810
  return true;
11493
11811
  }
@@ -12483,6 +12801,24 @@ var init_session_manager = __esm({
12483
12801
  dropUIState(sessionId) {
12484
12802
  this.uiStore.dropUIState(sessionId);
12485
12803
  }
12804
+ /** Another process (a cron `halo cli` child) just wrote this root session's
12805
+ * files. Drop the idle in-memory copies — the tree's AgentSessions (raw
12806
+ * history, loaded by any view via ensureSession and never evicted while
12807
+ * idle) and the root's UIState — WITHOUT saving, so the next view/message
12808
+ * re-reads disk instead of overwriting the other writer's turn with this
12809
+ * process's stale snapshot. Returns false (and drops nothing) while any
12810
+ * turn/compact in the tree is in flight: live state can't be discarded,
12811
+ * and the two writers race — the accepted cost of sharing a session. */
12812
+ forgetExternalWrite(rootId) {
12813
+ if (this.hasActiveWorkInTree(rootId))
12814
+ return false;
12815
+ for (const id of [...this.sessions.keys()]) {
12816
+ if (id === rootId || id.startsWith(`${rootId}>`))
12817
+ this.sessions.delete(id);
12818
+ }
12819
+ this.uiStore.dropUIState(rootId);
12820
+ return true;
12821
+ }
12486
12822
  // ── Loop detection ──────────────────────────────────────────────────
12487
12823
  static LOOP_EXEMPT_TOOLS = /* @__PURE__ */ new Set([
12488
12824
  "session_list",
@@ -19077,7 +19413,7 @@ function createAgentConfigRoutes() {
19077
19413
  if (wsIds.has(a.id))
19078
19414
  a.overridden = true;
19079
19415
  }
19080
- const disabledSet = projectId ? getDisabledSet(getWorkspaceDb(projectId).db, "agent") : /* @__PURE__ */ new Set();
19416
+ const disabledSet = projectId && hasWorkspaceHalo(projectId) ? getDisabledSet(getWorkspaceDb(projectId).db, "agent") : /* @__PURE__ */ new Set();
19081
19417
  const allAgents = [...globalAgents, ...workspaceAgents];
19082
19418
  for (const a of allAgents) {
19083
19419
  a.disabled = disabledSet.has(`${a.scope}:${a.id}`);
@@ -19427,6 +19763,7 @@ var init_agent_configs = __esm({
19427
19763
  init_md_loader();
19428
19764
  init_config();
19429
19765
  init_db();
19766
+ init_init();
19430
19767
  init_workspace_path();
19431
19768
  init_mtime_cache();
19432
19769
  _cachedTools = null;
@@ -19451,7 +19788,7 @@ function defaultSkillSettings() {
19451
19788
  "",
19452
19789
  "global.anthropic.claude-sonnet-4-6",
19453
19790
  "global.anthropic.claude-haiku-4-5-20251001",
19454
- "global.anthropic.claude-opus-4-6"
19791
+ "global.anthropic.claude-opus-5-5"
19455
19792
  ]
19456
19793
  }
19457
19794
  };
@@ -20498,6 +20835,8 @@ function createSessionRoutes(smRegistry) {
20498
20835
  return c.json({ error: "session manager not initialized" }, 500);
20499
20836
  if (!projectId)
20500
20837
  return c.json({ error: "projectId required" }, 400);
20838
+ if (!hasWorkspaceHalo(projectId))
20839
+ return c.json({ sessions: [], nextCursor: null });
20501
20840
  const sm = smRegistry.getOrCreate(projectId);
20502
20841
  let parentId;
20503
20842
  if (parentIdParam === void 0)
@@ -20647,6 +20986,7 @@ var init_sessions = __esm({
20647
20986
  init_goal_mode();
20648
20987
  init_broadcast();
20649
20988
  init_workspace_path();
20989
+ init_init();
20650
20990
  msgCounter2 = 0;
20651
20991
  }
20652
20992
  });
@@ -23618,7 +23958,8 @@ ${msg.message}` : markers.join("\n");
23618
23958
  rememberChat(msg);
23619
23959
  ackChat(msg);
23620
23960
  console.debug(`[WS] Chat: session=${msg.sessionId}, project=${msg.projectId}, agent=${msg.agentId ?? client2.agentId}`);
23621
- sm.sendUserMessage(sid, msg.message, msg.images).catch((err) => {
23961
+ const accessLevel = msg.accessLevel === void 0 ? void 0 : msg.accessLevel === "full" || getSandboxBackend() === null ? null : msg.accessLevel;
23962
+ sm.sendUserMessage(sid, msg.message, msg.images, accessLevel).catch((err) => {
23622
23963
  console.debug(`[WS] Chat error: ${err instanceof Error ? err.message : String(err)}`);
23623
23964
  sendJson(ws, { type: "error", error: err instanceof Error ? err.message : String(err) });
23624
23965
  saveSession(client2);
@@ -23678,7 +24019,7 @@ ${msg.message}` : markers.join("\n");
23678
24019
  const running = client2.sessionManager.isSessionRunning(client2.sessionId);
23679
24020
  const messages3 = state2 ? running ? [...state2.messageLog] : [...createSaveSnapshot(state2)] : [];
23680
24021
  const detachedSession = client2.sessionManager.getSessionById(client2.sessionId);
23681
- sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages3, sessionId: msg.sessionId, maxContextTokens: ctxConfig.maxTokens, agentId: detachedSession?.agentId, archiveCount: archiveCountFor(client2, client2.sessionId, detachedSession?.agentId) } });
24022
+ sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages3, sessionId: msg.sessionId, maxContextTokens: ctxConfig.maxTokens, agentId: detachedSession?.agentId, archiveCount: archiveCountFor(client2, client2.sessionId, detachedSession?.agentId), accessLevel: detachedSession?.accessLevel ?? null } });
23682
24023
  if (state2 && state2.contextTokens > 0) {
23683
24024
  sendJson(ws, { type: "chat:usage", contextTokens: state2.contextTokens, outputTokens: state2.outputTokens });
23684
24025
  }
@@ -23722,12 +24063,14 @@ ${msg.message}` : markers.join("\n");
23722
24063
  }
23723
24064
  }
23724
24065
  let agentId;
24066
+ let accessLevel;
23725
24067
  if (msg.sessionId && client2.sessionManager) {
23726
24068
  const existingSession = client2.sessionManager.getSessionById(msg.sessionId);
23727
24069
  if (existingSession) {
23728
24070
  client2.sessionId = msg.sessionId;
23729
24071
  client2.unsubscribeEvents = client2.sessionManager.registerEventListener(msg.sessionId, createEventListener(client2));
23730
24072
  agentId = existingSession.agentId;
24073
+ accessLevel = existingSession.accessLevel ?? null;
23731
24074
  }
23732
24075
  }
23733
24076
  let maxContextTokens = config.model.maxContextTokens;
@@ -23739,7 +24082,7 @@ ${msg.message}` : markers.join("\n");
23739
24082
  }
23740
24083
  const state = getState(client2);
23741
24084
  const messages2 = state ? [...createSaveSnapshot(state)] : [];
23742
- sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages2, sessionId: msg.sessionId, maxContextTokens, agentId, archiveCount: msg.sessionId ? archiveCountFor(client2, msg.sessionId, agentId) : 0 } });
24085
+ sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages2, sessionId: msg.sessionId, maxContextTokens, agentId, archiveCount: msg.sessionId ? archiveCountFor(client2, msg.sessionId, agentId) : 0, accessLevel } });
23743
24086
  if (state && state.contextTokens > 0) {
23744
24087
  sendJson(ws, { type: "chat:usage", contextTokens: state.contextTokens, outputTokens: state.outputTokens });
23745
24088
  }
@@ -23768,6 +24111,7 @@ var CLIENT_SILENCE_LIMIT_MS, detachedSessions;
23768
24111
  var init_handler2 = __esm({
23769
24112
  "../server/dist/ws/handler.js"() {
23770
24113
  "use strict";
24114
+ init_sandbox();
23771
24115
  init_ui_log_builder();
23772
24116
  init_config();
23773
24117
  init_watcher_pool();
@@ -23838,6 +24182,10 @@ var init_cron_db = __esm({
23838
24182
  * it. NULL = default 3600 (runner.ts CLI_TIMEOUT_SEC). Range 60–21600,
23839
24183
  * enforced at the write points (REST routes / skill helper). */
23840
24184
  timeoutSec: integer5("timeout_sec"),
24185
+ /** Root session id the cli runs in. NULL = the job's own `cron-<jobId>`
24186
+ * session. When it names an existing session the cli resumes it with that
24187
+ * session's own agent (`agentId` only applies when the session is new). */
24188
+ sessionId: text5("session_id"),
23841
24189
  /** JSON array of `{channelType, accountId}` records. Cron output (final
23842
24190
  * assistant text) is dispatched to each one. Empty array = log only. */
23843
24191
  targets: text5("targets").notNull().default("[]"),
@@ -23888,6 +24236,7 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
23888
24236
  run_at INTEGER,
23889
24237
  timezone TEXT,
23890
24238
  timeout_sec INTEGER,
24239
+ session_id TEXT,
23891
24240
  targets TEXT NOT NULL DEFAULT '[]',
23892
24241
  enabled INTEGER NOT NULL DEFAULT 1,
23893
24242
  last_run_status TEXT,
@@ -23923,7 +24272,9 @@ CREATE INDEX IF NOT EXISTS idx_cron_runs_status ON cron_runs(status);
23923
24272
  addColumnIfMissing(s, "cron_jobs", "run_at", "INTEGER");
23924
24273
  addColumnIfMissing(s, "cron_jobs", "timeout_sec", "INTEGER");
23925
24274
  addColumnIfMissing(s, "cron_runs", "pid", "INTEGER");
23926
- }
24275
+ },
24276
+ // v2: `session_id` — run the job in a chosen root session instead of cron-<jobId>.
24277
+ (s) => addColumnIfMissing(s, "cron_jobs", "session_id", "TEXT")
23927
24278
  ];
23928
24279
  _cronDb = null;
23929
24280
  }
@@ -24048,14 +24399,30 @@ function killTreeHard(rootPid) {
24048
24399
  }
24049
24400
  }
24050
24401
  }
24051
- function isCronCliProcess(pid, jobId) {
24402
+ function isCronCliProcess(pid, sessionId) {
24052
24403
  try {
24053
24404
  const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], { encoding: "utf-8" });
24054
- return args.includes(`cron-${jobId}`);
24405
+ return args.trim().split(/\s+/).includes(sessionId);
24055
24406
  } catch {
24056
24407
  return false;
24057
24408
  }
24058
24409
  }
24410
+ function effectiveSessionId(job) {
24411
+ return job.sessionId ?? `cron-${job.id}`;
24412
+ }
24413
+ function sessionKeyFor(workspacePath, sessionId) {
24414
+ let ws;
24415
+ try {
24416
+ ws = fs51.realpathSync(workspacePath);
24417
+ } catch {
24418
+ ws = path60.resolve(workspacePath);
24419
+ }
24420
+ return `${ws}
24421
+ ${sessionId}`;
24422
+ }
24423
+ function setCronSessionRegistry(r) {
24424
+ _sessionRegistry = r;
24425
+ }
24059
24426
  function logsDir() {
24060
24427
  return path60.join(homedir23(), ".halo", "global", "logs", "cron");
24061
24428
  }
@@ -24072,14 +24439,24 @@ function sweepOrphanRuns(opts) {
24072
24439
  const now = Date.now();
24073
24440
  for (const row of rows) {
24074
24441
  const { id: runId, jobId, pid } = row;
24442
+ const job = db.select().from(cronJobs).where(eq14(cronJobs.id, jobId)).get();
24443
+ const sessionId = effectiveSessionId(job ?? { id: jobId, sessionId: null });
24444
+ const sessionKey = job ? sessionKeyFor(job.workspacePath, sessionId) : null;
24075
24445
  _inflight.add(jobId);
24446
+ if (sessionKey)
24447
+ _inflightSessions.add(sessionKey);
24448
+ const release = () => {
24449
+ _inflight.delete(jobId);
24450
+ if (sessionKey)
24451
+ _inflightSessions.delete(sessionKey);
24452
+ };
24076
24453
  let needsKill = false;
24077
24454
  let disposition;
24078
24455
  if (process.platform === "win32") {
24079
24456
  disposition = pid == null ? "no pid recorded" : `pid ${pid} not killed (Windows: cannot verify process identity)`;
24080
24457
  } else if (pid == null) {
24081
24458
  disposition = "no pid recorded";
24082
- } else if (!isCronCliProcess(pid, jobId)) {
24459
+ } else if (!isCronCliProcess(pid, sessionId)) {
24083
24460
  disposition = `pid ${pid} already dead (or pid reused by another process); not killed`;
24084
24461
  } else {
24085
24462
  needsKill = true;
@@ -24093,7 +24470,7 @@ function sweepOrphanRuns(opts) {
24093
24470
  broadcast({ type: "cron:run_changed", jobId, runId, status: "failed" });
24094
24471
  console.log(`[Cron] orphan sweep: run ${runId} (job ${jobId}) \u2014 ${disposition}`);
24095
24472
  if (!needsKill || pid == null) {
24096
- _inflight.delete(jobId);
24473
+ release();
24097
24474
  continue;
24098
24475
  }
24099
24476
  try {
@@ -24101,11 +24478,11 @@ function sweepOrphanRuns(opts) {
24101
24478
  } catch {
24102
24479
  }
24103
24480
  const timer = setTimeout(() => {
24104
- if (isCronCliProcess(pid, jobId)) {
24481
+ if (isCronCliProcess(pid, sessionId)) {
24105
24482
  console.log(`[Cron] orphan sweep: pid ${pid} (job ${jobId}) survived SIGTERM \u2014 SIGKILL tree`);
24106
24483
  killTreeHard(pid);
24107
24484
  }
24108
- _inflight.delete(jobId);
24485
+ release();
24109
24486
  }, graceMs);
24110
24487
  timer.unref();
24111
24488
  }
@@ -24269,7 +24646,11 @@ async function runJob(jobId, triggerKind) {
24269
24646
  const startedAt = Date.now();
24270
24647
  const logPath = logPathFor(runId);
24271
24648
  fs51.mkdirSync(logsDir(), { recursive: true });
24272
- if (_inflight.has(jobId)) {
24649
+ const sessionId = effectiveSessionId(job);
24650
+ const sessionKey = sessionKeyFor(job.workspacePath, sessionId);
24651
+ const host = _sessionRegistry?.peek(job.workspacePath);
24652
+ const skipReason = _inflight.has(jobId) ? "previous run still in progress" : _inflightSessions.has(sessionKey) ? `session ${sessionId} busy: another cron run is in progress` : host?.hasActiveWorkInTree(sessionId) ? `session ${sessionId} busy: a turn is running in it` : null;
24653
+ if (skipReason) {
24273
24654
  db.insert(cronRuns).values({
24274
24655
  id: runId,
24275
24656
  jobId,
@@ -24279,7 +24660,7 @@ async function runJob(jobId, triggerKind) {
24279
24660
  completedAt: startedAt,
24280
24661
  output: null,
24281
24662
  exitCode: null,
24282
- failureReason: "previous run still in progress",
24663
+ failureReason: skipReason,
24283
24664
  logPath: null,
24284
24665
  dispatchResults: null
24285
24666
  }).run();
@@ -24287,6 +24668,7 @@ async function runJob(jobId, triggerKind) {
24287
24668
  return runId;
24288
24669
  }
24289
24670
  _inflight.add(jobId);
24671
+ _inflightSessions.add(sessionKey);
24290
24672
  try {
24291
24673
  db.insert(cronRuns).values({
24292
24674
  id: runId,
@@ -24314,14 +24696,16 @@ async function runJob(jobId, triggerKind) {
24314
24696
  return runId;
24315
24697
  }
24316
24698
  const cli = resolveHaloCli();
24317
- const sessionId = `cron-${job.id}`;
24318
24699
  const args = ["cli", "-a", job.agentId, "-s", sessionId, "-w", job.workspacePath];
24700
+ const accessLevel = readSessionAccessLevel(job.workspacePath, sessionId);
24701
+ if (accessLevel)
24702
+ args.push("--access", accessLevel);
24319
24703
  const [spawnBin, spawnArgs] = process.platform === "win32" && cli.endsWith(".cmd") ? ["cmd.exe", ["/c", cli, ...args]] : [cli, args];
24320
24704
  const timeoutSec = job.timeoutSec ?? CLI_TIMEOUT_SEC;
24321
24705
  const teeStream = fs51.createWriteStream(logPath, { flags: "a" });
24322
24706
  teeStream.write(`=== ${(/* @__PURE__ */ new Date()).toISOString()} cron run ${runId} job=${jobId} trigger=${triggerKind} ===
24323
24707
  `);
24324
- teeStream.write(`agent=${job.agentId} workspace=${job.workspacePath} schedule=${job.schedule}
24708
+ teeStream.write(`agent=${job.agentId} session=${sessionId}${accessLevel ? ` access=${accessLevel}` : ""} workspace=${job.workspacePath} schedule=${job.schedule}
24325
24709
  `);
24326
24710
  teeStream.write(`prompt: ${job.userPrompt}
24327
24711
 
@@ -24393,6 +24777,10 @@ ${job.userPrompt}`);
24393
24777
  resolve2({ exitCode: 1 });
24394
24778
  });
24395
24779
  });
24780
+ const hostAfter = _sessionRegistry?.peek(job.workspacePath);
24781
+ if (hostAfter && !hostAfter.forgetExternalWrite(sessionId)) {
24782
+ console.warn(`[Cron] ${jobId}: session ${sessionId} had a server-side turn running when the cli exited; one of the two turns may be missing from its history`);
24783
+ }
24396
24784
  const trimmedOut = stdout.trim();
24397
24785
  let status;
24398
24786
  let failureReason = null;
@@ -24427,6 +24815,17 @@ ${job.userPrompt}`);
24427
24815
  return runId;
24428
24816
  } finally {
24429
24817
  _inflight.delete(jobId);
24818
+ _inflightSessions.delete(sessionKey);
24819
+ }
24820
+ }
24821
+ function readSessionAccessLevel(workspacePath, sessionId) {
24822
+ try {
24823
+ const row = getWorkspaceDb(workspacePath).db.select({ accessLevel: agentSessions.accessLevel }).from(agentSessions).where(eq14(agentSessions.id, sessionId)).get();
24824
+ if (!row)
24825
+ return null;
24826
+ return row.accessLevel === "workspace" || row.accessLevel === "readonly" ? row.accessLevel : "full";
24827
+ } catch {
24828
+ return null;
24430
24829
  }
24431
24830
  }
24432
24831
  function finalize(runId, jobId, a) {
@@ -24508,12 +24907,14 @@ function pruneOldLogs() {
24508
24907
  const dbCutoff = Date.now() - 2 * LOG_RETENTION_DAYS * DAY_MS2;
24509
24908
  db.delete(cronRuns).where(lt5(cronRuns.startedAt, dbCutoff)).run();
24510
24909
  }
24511
- var DAY_MS2, CLI_TIMEOUT_SEC, KILL_GRACE_SEC, UNATTENDED_PREAMBLE, RUNS_PER_JOB_KEEP, LOG_RETENTION_DAYS, _active, _inflight, _fingerprint, _daemonTimers, _lastCronDataVersion;
24910
+ var DAY_MS2, CLI_TIMEOUT_SEC, KILL_GRACE_SEC, UNATTENDED_PREAMBLE, RUNS_PER_JOB_KEEP, LOG_RETENTION_DAYS, _sessionRegistry, _active, _inflight, _inflightSessions, _fingerprint, _daemonTimers, _lastCronDataVersion;
24512
24911
  var init_runner = __esm({
24513
24912
  "../server/dist/cron/runner.js"() {
24514
24913
  "use strict";
24515
24914
  init_cron_db();
24516
24915
  init_raw_sqlite();
24916
+ init_db();
24917
+ init_schema();
24517
24918
  init_dispatcher();
24518
24919
  init_broadcast();
24519
24920
  init_child_env();
@@ -24523,8 +24924,10 @@ var init_runner = __esm({
24523
24924
  UNATTENDED_PREAMBLE = "[Unattended cron run \u2014 nobody will answer questions. Deliver the result directly; if genuinely blocked, state what's blocked in one line and stop.]";
24524
24925
  RUNS_PER_JOB_KEEP = 100;
24525
24926
  LOG_RETENTION_DAYS = 30;
24927
+ _sessionRegistry = null;
24526
24928
  _active = /* @__PURE__ */ new Map();
24527
24929
  _inflight = /* @__PURE__ */ new Set();
24930
+ _inflightSessions = /* @__PURE__ */ new Set();
24528
24931
  _fingerprint = /* @__PURE__ */ new Map();
24529
24932
  _daemonTimers = [];
24530
24933
  _lastCronDataVersion = null;
@@ -24556,6 +24959,19 @@ function validateTimeoutSec(v) {
24556
24959
  }
24557
24960
  return null;
24558
24961
  }
24962
+ function parseSessionId(v) {
24963
+ if (v === void 0 || v === null)
24964
+ return { sessionId: null };
24965
+ if (typeof v !== "string")
24966
+ return { error: "sessionId must be a string or null" };
24967
+ const s = v.trim();
24968
+ if (s.length === 0)
24969
+ return { sessionId: null };
24970
+ if (!SESSION_ID_RE.test(s)) {
24971
+ return { error: 'sessionId must be a root session id (letters, digits, _ : - only; no ">" sub-session path)' };
24972
+ }
24973
+ return { sessionId: s };
24974
+ }
24559
24975
  function createCronRoutes() {
24560
24976
  const router = new Hono14();
24561
24977
  router.get("/cron/jobs", async (c) => {
@@ -24604,6 +25020,9 @@ function createCronRoutes() {
24604
25020
  if (err)
24605
25021
  return c.json({ error: err }, 400);
24606
25022
  }
25023
+ const session = parseSessionId(body.sessionId);
25024
+ if ("error" in session)
25025
+ return c.json({ error: session.error }, 400);
24607
25026
  const id = newJobId();
24608
25027
  const now = Date.now();
24609
25028
  getCronDb().insert(cronJobs).values({
@@ -24616,6 +25035,7 @@ function createCronRoutes() {
24616
25035
  runAt: hasRunAt ? body.runAt : null,
24617
25036
  timezone: body.timezone ?? null,
24618
25037
  timeoutSec: body.timeoutSec ?? null,
25038
+ sessionId: session.sessionId,
24619
25039
  targets: JSON.stringify(body.targets ?? []),
24620
25040
  enabled: body.enabled === false ? 0 : 1,
24621
25041
  lastRunStatus: null,
@@ -24657,6 +25077,9 @@ function createCronRoutes() {
24657
25077
  if (err)
24658
25078
  return c.json({ error: err }, 400);
24659
25079
  }
25080
+ const session = parseSessionId(body.sessionId);
25081
+ if ("error" in session)
25082
+ return c.json({ error: session.error }, 400);
24660
25083
  const hasSchedule = typeof body.schedule === "string" && body.schedule.trim().length > 0;
24661
25084
  const hasRunAt = typeof body.runAt === "number";
24662
25085
  if (hasSchedule && hasRunAt) {
@@ -24686,6 +25109,8 @@ function createCronRoutes() {
24686
25109
  patch.timezone = body.timezone || null;
24687
25110
  if (body.timeoutSec !== void 0)
24688
25111
  patch.timeoutSec = body.timeoutSec;
25112
+ if (body.sessionId !== void 0)
25113
+ patch.sessionId = session.sessionId;
24689
25114
  if (body.targets !== void 0)
24690
25115
  patch.targets = JSON.stringify(body.targets);
24691
25116
  if (body.enabled !== void 0)
@@ -24801,7 +25226,7 @@ function computeNextRun(j) {
24801
25226
  return null;
24802
25227
  }
24803
25228
  }
24804
- var TIMEOUT_SEC_MIN, TIMEOUT_SEC_MAX;
25229
+ var TIMEOUT_SEC_MIN, TIMEOUT_SEC_MAX, SESSION_ID_RE;
24805
25230
  var init_cron = __esm({
24806
25231
  "../server/dist/routes/cron.js"() {
24807
25232
  "use strict";
@@ -24811,6 +25236,7 @@ var init_cron = __esm({
24811
25236
  init_broadcast();
24812
25237
  TIMEOUT_SEC_MIN = 60;
24813
25238
  TIMEOUT_SEC_MAX = 21600;
25239
+ SESSION_ID_RE = /^[A-Za-z0-9_:-]{1,200}$/;
24814
25240
  }
24815
25241
  });
24816
25242
 
@@ -25544,7 +25970,7 @@ function startTelegramChannel(deps) {
25544
25970
  if (!route)
25545
25971
  return;
25546
25972
  if (!isMediaPathAllowed(filePath, account.workspacePath)) {
25547
- console.log(`[Telegram] sendMedia blocked: ${filePath} not under workspace`);
25973
+ console.warn(`[Telegram] sendMedia blocked: ${filePath} not under workspace`);
25548
25974
  return;
25549
25975
  }
25550
25976
  const kind = inferMediaKind(filePath);
@@ -26841,7 +27267,7 @@ function startWechatChannel(deps) {
26841
27267
  if (!route)
26842
27268
  return;
26843
27269
  if (!isMediaPathAllowed(filePath, account.workspacePath)) {
26844
- console.log(`[WeChat] sendMedia blocked: ${filePath} not under workspace`);
27270
+ console.warn(`[WeChat] sendMedia blocked: ${filePath} not under workspace`);
26845
27271
  return;
26846
27272
  }
26847
27273
  await sendMediaFile({
@@ -28347,7 +28773,7 @@ function startSlackChannel(deps) {
28347
28773
  return;
28348
28774
  const resolved = path65.resolve(filePath);
28349
28775
  if (!isMediaPathAllowed(resolved, account.workspacePath)) {
28350
- console.log(`[Slack] sendMedia blocked: ${filePath} not under workspace`);
28776
+ console.warn(`[Slack] sendMedia blocked: ${filePath} not under workspace`);
28351
28777
  return;
28352
28778
  }
28353
28779
  try {
@@ -29493,7 +29919,7 @@ function startFeishuChannel(deps) {
29493
29919
  return;
29494
29920
  const resolved = path66.resolve(filePath);
29495
29921
  if (!isMediaPathAllowed(resolved, account.workspacePath)) {
29496
- console.log(`[Feishu] sendMedia blocked: ${filePath} not under workspace`);
29922
+ console.warn(`[Feishu] sendMedia blocked: ${filePath} not under workspace`);
29497
29923
  return;
29498
29924
  }
29499
29925
  try {
@@ -30356,7 +30782,7 @@ function startWecomChannel(deps) {
30356
30782
  return;
30357
30783
  const resolved = path67.resolve(filePath);
30358
30784
  if (!isMediaPathAllowed(resolved, account.workspacePath)) {
30359
- console.log(`[WeCom] sendMedia blocked: ${filePath} not under workspace`);
30785
+ console.warn(`[WeCom] sendMedia blocked: ${filePath} not under workspace`);
30360
30786
  return;
30361
30787
  }
30362
30788
  try {
@@ -31166,7 +31592,7 @@ var init_dist2 = __esm({
31166
31592
  init_git_ssh();
31167
31593
  __dirname3 = path68.dirname(fileURLToPath3(import.meta.url));
31168
31594
  PORT = config.server.port;
31169
- HALO_VERSION = "1.3.4-c3090bf";
31595
+ HALO_VERSION = "1.4.0";
31170
31596
  PROJECT_ROOT = findProjectRoot();
31171
31597
  HALO_HOME4 = path68.join(homedir24(), ".halo");
31172
31598
  GIT_SHA = readGitSha();
@@ -31213,7 +31639,7 @@ var init_dist2 = __esm({
31213
31639
  const { hiddenDirs, hiddenFiles, writableDirs } = reloadSandboxConfig();
31214
31640
  setSandboxHiddenPaths(hiddenDirs, hiddenFiles, writableDirs);
31215
31641
  });
31216
- console.log(`[Server] bwrap sandbox: ${isBwrapCached() ? "available" : "NOT available (app-level fallback only)"}`);
31642
+ console.log(`[Server] OS sandbox: ${getSandboxBackend() ?? "NOT available (non-full sessions: no shell_exec, file tools path-checked in-process)"}`);
31217
31643
  ensureSshAgent();
31218
31644
  console.log(`[Server] Halo home: ${HALO_HOME4}`);
31219
31645
  console.log("[Server] Services initialized (ModelRuntime)");
@@ -31242,7 +31668,10 @@ var init_dist2 = __esm({
31242
31668
  uptime: process.uptime(),
31243
31669
  engine: "agent",
31244
31670
  version: HALO_VERSION,
31245
- gitSha: GIT_SHA
31671
+ gitSha: GIT_SHA,
31672
+ // OS sandbox backing non-full access levels; null → admin locks the
31673
+ // access-level selector to Full.
31674
+ sandbox: getSandboxBackend()
31246
31675
  });
31247
31676
  });
31248
31677
  fileRoutes = createFileRoutes();
@@ -31273,6 +31702,7 @@ var init_dist2 = __esm({
31273
31702
  }
31274
31703
  registry = new SessionManagerRegistry({ reconcileOrphansOnBoot: true });
31275
31704
  setRelayRegistry(registry);
31705
+ setCronSessionRegistry(registry);
31276
31706
  for (const ws of listRunningWorkspaces()) {
31277
31707
  if (!fs62.existsSync(path68.join(ws, ".halo")))
31278
31708
  continue;
@@ -32101,7 +32531,7 @@ import path69 from "node:path";
32101
32531
  import fs63 from "node:fs";
32102
32532
  import { homedir as homedir25 } from "node:os";
32103
32533
  import { parseArgs as parseArgs2 } from "node:util";
32104
- var VERSION = "1.3.4-c3090bf";
32534
+ var VERSION = "1.4.0";
32105
32535
  var HELP_TOP = `Usage: halo <command> [options]
32106
32536
 
32107
32537
  Commands: