@turmind/halo 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2339,6 +2339,13 @@ function ensureHaloHome(haloHome2) {
2339
2339
  fs7.rmSync(staleWsSkill, { recursive: true, force: true });
2340
2340
  console.log("[Init] Removed stale `ws` skill (renamed to `workspace`)");
2341
2341
  }
2342
+ for (const staleId of ["nova-web-search", "openai-web-search"]) {
2343
+ const staleDir = path8.join(globalDir2, "skills", staleId);
2344
+ if (fs7.existsSync(staleDir)) {
2345
+ fs7.rmSync(staleDir, { recursive: true, force: true });
2346
+ console.log(`[Init] Removed stale \`${staleId}\` skill (merged into \`web-search\`)`);
2347
+ }
2348
+ }
2342
2349
  const docsSrc = resolveDocsSource();
2343
2350
  if (!docsSrc) {
2344
2351
  console.log(`[Init] No docs source found \u2014 skipping platform docs seed. Searched: ${DOCS_SRC_CANDIDATES.join(", ")}`);
@@ -2400,7 +2407,7 @@ var init_init = __esm({
2400
2407
  path8.resolve(__dirname, "..", "bundled-docs"),
2401
2408
  path8.resolve(__dirname, "..", "..", "..", ".halo", "docs")
2402
2409
  ];
2403
- TEMPLATE_VERSION = 49;
2410
+ TEMPLATE_VERSION = 52;
2404
2411
  VERSION_FILE = ".template-version";
2405
2412
  SKIP_NAMES = /* @__PURE__ */ new Set([".DS_Store", "schema.sql", "__pycache__", ".pytest_cache"]);
2406
2413
  BUILTIN_AGENT_IDS = /* @__PURE__ */ new Set([
@@ -2434,7 +2441,7 @@ var init_init = __esm({
2434
2441
  // Capability skills, model-invoked only (user-invocable: false — no slash
2435
2442
  // command). Preinstalled because they're broadly useful out of the box.
2436
2443
  "aws-knowledge",
2437
- "nova-web-search",
2444
+ "web-search",
2438
2445
  // The agent's visual "face": teaches it that `.halo/canvas/self.html`
2439
2446
  // (seeded per-workspace, see ensureWorkspaceHalo) is a live self-portrait
2440
2447
  // it can drive in real time by emitting `<<<SHOW: …js… >>>`, which the admin
@@ -3324,7 +3331,12 @@ async function deliverGoalRound(host, worker) {
3324
3331
  const activeChildren = db.select({ id: agentSessions.id }).from(agentSessions).where(and2(eq2(agentSessions.parentId, worker.id), isNull(agentSessions.stoppedAt), isNull(agentSessions.archivedAt))).all();
3325
3332
  if (activeChildren.length > 0 || worker.messageQueue.length > 0)
3326
3333
  return;
3327
- const report = worker.finalOutput || worker.output || "(no output)";
3334
+ let report = worker.finalOutput || worker.output || "(no output)";
3335
+ if (worker.turnError) {
3336
+ report = `[WORKER ABORTED: this round was terminated by an unrecoverable error, NOT a normal wrap-up. Error: ${worker.turnError}. The text below is a partial trace of the aborted turn \u2014 judge this as an interrupted round, do not score it as a completed result. Re-dispatch with query_session to let the worker resume.]
3337
+
3338
+ ${report}`;
3339
+ }
3328
3340
  const caps = state.caps;
3329
3341
  const currentSpecHash = hashFileOrNull(goalSpecPath(host.workspaceRoot, state.goalId));
3330
3342
  if (!currentSpecHash || currentSpecHash !== state.specHash) {
@@ -5127,6 +5139,17 @@ var init_session_ui_store = __esm({
5127
5139
  /** Last activity (event reduced / view built) per uiStates key — the idle
5128
5140
  * sweep's eviction clock. Invariant: every uiStates key has an entry. */
5129
5141
  uiStateTouched = /* @__PURE__ */ new Map();
5142
+ /** Roots whose in-memory UIState holds local mutations (event reduced in,
5143
+ * notification appended, log replaced) **not yet persisted** — marked on
5144
+ * every mutation, cleared once persistUIState lands the snapshot. A state
5145
+ * not in this set adds nothing to what's on disk: either it's a pure disk
5146
+ * seed (built to *view* a session another process may be driving — a cron
5147
+ * `halo cli` child shares the workspace's session files and keeps appending
5148
+ * after the seed), or its mutations were already flushed. The WS
5149
+ * detach/switch saves gate on `isUIStateDirty`: writing a clean state back
5150
+ * would overwrite the other process's newer messages with this process's
5151
+ * frozen snapshot (the cron-session UI-log truncation incident). */
5152
+ uiStateDirty = /* @__PURE__ */ new Set();
5130
5153
  constructor(host) {
5131
5154
  this.host = host;
5132
5155
  this.db = host.getDb();
@@ -5223,6 +5246,7 @@ var init_session_ui_store = __esm({
5223
5246
  reduceIntoUIState(rootId, event) {
5224
5247
  try {
5225
5248
  const state = this.ensureUIState(rootId);
5249
+ this.uiStateDirty.add(rootId);
5226
5250
  const result = applyEvent(state, event);
5227
5251
  if (result.subSessionSave) {
5228
5252
  this.persistSubSession(state, rootId, result.subSessionSave);
@@ -5319,6 +5343,7 @@ var init_session_ui_store = __esm({
5319
5343
  archiveCount: archive?.count,
5320
5344
  archivedUserDelta: archive?.userDelta
5321
5345
  });
5346
+ this.uiStateDirty.delete(rootId);
5322
5347
  } catch (err) {
5323
5348
  console.error(`[SessionUIStore] persistUIState failed for ${rootId}: ${err instanceof Error ? err.message : String(err)}`);
5324
5349
  }
@@ -5438,6 +5463,14 @@ var init_session_ui_store = __esm({
5438
5463
  getCachedUIState(rootSessionId) {
5439
5464
  return this.uiStates.get(rootSessionId) ?? null;
5440
5465
  }
5466
+ /** Whether the cached UIState holds local mutations not yet persisted.
5467
+ * False for a state that was only seeded from disk (view of a session
5468
+ * another process drives) or whose mutations already flushed — writing
5469
+ * such a state back is at best a no-op and at worst overwrites a fresher
5470
+ * file (see uiStateDirty). */
5471
+ isUIStateDirty(rootSessionId) {
5472
+ return this.uiStateDirty.has(rootSessionId);
5473
+ }
5441
5474
  /**
5442
5475
  * Get the UIState for a session, restoring it from disk if it isn't
5443
5476
  * in memory yet. Returns null only when the session id doesn't exist
@@ -5470,6 +5503,7 @@ var init_session_ui_store = __esm({
5470
5503
  timestamp: Date.now(),
5471
5504
  agentName
5472
5505
  });
5506
+ this.uiStateDirty.add(rootId);
5473
5507
  this.persistUIState(rootId, state);
5474
5508
  }
5475
5509
  /**
@@ -5483,6 +5517,7 @@ var init_session_ui_store = __esm({
5483
5517
  state.streamBuffer = "";
5484
5518
  state.turnToolCalls = [];
5485
5519
  state.turnContentBlocks = [];
5520
+ this.uiStateDirty.add(rootId);
5486
5521
  this.persistUIState(rootId, state);
5487
5522
  }
5488
5523
  /**
@@ -5504,6 +5539,7 @@ var init_session_ui_store = __esm({
5504
5539
  this.uiStates.delete(sessionId);
5505
5540
  this.uiStateProjectPaths.delete(sessionId);
5506
5541
  this.uiStateTouched.delete(sessionId);
5542
+ this.uiStateDirty.delete(sessionId);
5507
5543
  }
5508
5544
  /**
5509
5545
  * Evict UIStates whose tree has been idle past the TTL. This is THE eviction
@@ -5560,8 +5596,10 @@ var init_session_ui_store = __esm({
5560
5596
  * session evolving elsewhere would freeze on the first snapshot.
5561
5597
  */
5562
5598
  prepareForView(sessionId, selfDriven) {
5563
- if (!selfDriven)
5599
+ if (!selfDriven) {
5564
5600
  this.uiStates.delete(sessionId);
5601
+ this.uiStateDirty.delete(sessionId);
5602
+ }
5565
5603
  this.uiStateProjectPaths.set(sessionId, this.host.workspaceRoot);
5566
5604
  return this.ensureUIState(sessionId);
5567
5605
  }
@@ -5571,6 +5609,7 @@ var init_session_ui_store = __esm({
5571
5609
  this.uiStates.delete(id);
5572
5610
  this.uiStateProjectPaths.delete(id);
5573
5611
  this.uiStateTouched.delete(id);
5612
+ this.uiStateDirty.delete(id);
5574
5613
  const timer = this.persistTimers.get(id);
5575
5614
  if (timer) {
5576
5615
  clearTimeout(timer);
@@ -10392,6 +10431,7 @@ var init_session_manager = __esm({
10392
10431
  draftReset,
10393
10432
  output: "",
10394
10433
  finalOutput: "",
10434
+ turnError: null,
10395
10435
  promise: null,
10396
10436
  abortController: null,
10397
10437
  messageQueue: [],
@@ -10695,6 +10735,13 @@ var init_session_manager = __esm({
10695
10735
  getCachedUIState(rootSessionId) {
10696
10736
  return this.uiStore.getCachedUIState(rootSessionId);
10697
10737
  }
10738
+ /** Whether a root's cached UIState holds local mutations not yet persisted.
10739
+ * The WS save paths gate on this — a clean state (pure disk seed, or
10740
+ * already flushed) must not be written back over a file that another
10741
+ * process (cron `halo cli` child) may have advanced since. */
10742
+ isUIStateDirty(rootSessionId) {
10743
+ return this.uiStore.isUIStateDirty(rootSessionId);
10744
+ }
10698
10745
  getUIState(rootSessionId) {
10699
10746
  return this.uiStore.getUIState(rootSessionId);
10700
10747
  }
@@ -10802,6 +10849,7 @@ var init_session_manager = __esm({
10802
10849
  const maxRetries = config.agent.maxRetries;
10803
10850
  session.output = "";
10804
10851
  session.finalOutput = "";
10852
+ session.turnError = null;
10805
10853
  for (let attempt = 0; attempt < maxRetries; attempt++) {
10806
10854
  session.abortController = new AbortController();
10807
10855
  const signal = session.abortController.signal;
@@ -10858,6 +10906,7 @@ var init_session_manager = __esm({
10858
10906
  if (msg.includes("insufficient balance") || msg.includes("suspended") || msg.includes("invalid api key") || msg.includes("Invalid API Key") || msg.includes("Unauthorized") || msg.includes("authentication")) {
10859
10907
  console.error(`[SessionManager] Session ${session.id} account error: ${msg}`);
10860
10908
  this.emitEvent(session.id, { type: "error", error: msg, agentName: session.agentName, taskId: session.parentId ? session.id : void 0 });
10909
+ session.turnError = msg;
10861
10910
  resultText = `Error: ${msg}`;
10862
10911
  break;
10863
10912
  }
@@ -10883,7 +10932,7 @@ var init_session_manager = __esm({
10883
10932
  continue;
10884
10933
  }
10885
10934
  }
10886
- if (msg === "fetch failed" || msg === "Model request timed out" || msg.includes("UND_ERR_HEADERS_TIMEOUT") || msg.includes("HeadersTimeoutError") || msg.includes("socket hang up") || msg.includes("ECONNRESET") || msg.includes("ECONNREFUSED") || msg.includes("ETIMEDOUT") || msg.includes("EAI_AGAIN")) {
10935
+ if (msg === "fetch failed" || msg === "Model request timed out" || msg.includes("http2 request did not get a response") || msg.includes("UND_ERR_HEADERS_TIMEOUT") || msg.includes("HeadersTimeoutError") || msg.includes("socket hang up") || msg.includes("ECONNRESET") || msg.includes("ECONNREFUSED") || msg.includes("ETIMEDOUT") || msg.includes("EAI_AGAIN")) {
10887
10936
  if (attempt + 1 < maxRetries) {
10888
10937
  const delay = 1e3 * Math.pow(2, attempt) + Math.random() * 500;
10889
10938
  console.debug(`[SessionManager] Session ${session.id} transient network error, retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxRetries}): ${msg}`);
@@ -10926,6 +10975,7 @@ var init_session_manager = __esm({
10926
10975
  const errDetail = [errName, httpStatus ? `HTTP ${httpStatus}` : ""].filter(Boolean).join(" ");
10927
10976
  console.error(`[SessionManager] Session ${session.id} error (attempt ${attempt + 1}/${maxRetries})${errDetail ? ` [${errDetail}]` : ""}: ${msg}`);
10928
10977
  this.emitEvent(session.id, { type: "error", error: msg, agentName: session.agentName, taskId: session.parentId ? session.id : void 0 });
10978
+ session.turnError = errName ? `${errName}: ${msg}` : msg;
10929
10979
  resultText = `Error: ${errName ? `${errName}: ` : ""}${msg}`;
10930
10980
  break;
10931
10981
  }
@@ -10991,7 +11041,12 @@ var init_session_manager = __esm({
10991
11041
  return;
10992
11042
  }
10993
11043
  this.db.update(agentSessions).set({ stoppedAt: Date.now() }).where(eq6(agentSessions.id, session.id)).run();
10994
- const result = session.finalOutput || session.output || "(no output)";
11044
+ let result = session.finalOutput || session.output || "(no output)";
11045
+ if (session.turnError) {
11046
+ result = `[SUB-AGENT ABORTED: the last turn was terminated by an unrecoverable error, NOT completed. Error: ${session.turnError}. The text below is a partial trace of the aborted turn \u2014 do not treat it as a finished result. Re-dispatch with query_session("${session.id}", ...) to let it resume.]
11047
+
11048
+ ${result}`;
11049
+ }
10995
11050
  console.debug(`[SessionManager] Auto-report: ${session.id} \u2192 parent ${session.parentId} \u2014 result: ${result.slice(0, 150)}`);
10996
11051
  const reportCap = config.limits.autoReportMax;
10997
11052
  const truncatedReport = result.length > reportCap ? result.slice(0, reportCap) + `
@@ -20492,14 +20547,75 @@ var init_commands3 = __esm({
20492
20547
  import { Hono as Hono13 } from "hono";
20493
20548
  import path52 from "node:path";
20494
20549
  import fs44 from "node:fs";
20550
+ function sanitizeRuntimeId(runtimeSessionId) {
20551
+ return runtimeSessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 80);
20552
+ }
20495
20553
  function haloSessionId(runtimeSessionId) {
20496
- return `agentcore_${runtimeSessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 80)}`;
20554
+ return `agentcore_${sanitizeRuntimeId(runtimeSessionId)}`;
20497
20555
  }
20498
20556
  function userWorkspace(base, runtimeSessionId) {
20499
- const dir = path52.join(base, "users", runtimeSessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 80));
20557
+ const dir = path52.join(base, "users", sanitizeRuntimeId(runtimeSessionId));
20500
20558
  fs44.mkdirSync(dir, { recursive: true });
20501
20559
  return dir;
20502
20560
  }
20561
+ function acceptChunk(uploads, msg) {
20562
+ const uploadId = msg.uploadId;
20563
+ const seq = msg.seq;
20564
+ const total = msg.total;
20565
+ const mimeType = msg.mimeType;
20566
+ const data = msg.data;
20567
+ if (typeof uploadId !== "string" || !uploadId || uploadId.length > 64)
20568
+ return "image_chunk: uploadId must be a non-empty string (\u226464 chars)";
20569
+ if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 0)
20570
+ return "image_chunk: seq must be a non-negative integer";
20571
+ if (typeof total !== "number" || !Number.isInteger(total) || total < 1 || total > MAX_CHUNKS_PER_UPLOAD)
20572
+ return `image_chunk: total must be 1..${MAX_CHUNKS_PER_UPLOAD}`;
20573
+ if (typeof mimeType !== "string" || typeof data !== "string" || !data)
20574
+ return "image_chunk: string mimeType and non-empty string data required";
20575
+ if (seq >= total)
20576
+ return "image_chunk: seq out of range";
20577
+ let up = uploads.get(uploadId);
20578
+ if (!up) {
20579
+ if (uploads.size >= MAX_PENDING_UPLOADS)
20580
+ return `too many pending uploads (max ${MAX_PENDING_UPLOADS} per connection)`;
20581
+ up = { chunks: new Array(total), received: 0, mimeType, size: 0 };
20582
+ uploads.set(uploadId, up);
20583
+ }
20584
+ if (up.chunks.length !== total)
20585
+ return "image_chunk: total mismatch across chunks of one uploadId";
20586
+ if (up.chunks[seq] !== void 0)
20587
+ return null;
20588
+ if (up.size + data.length > MAX_IMAGE_BASE64_LENGTH) {
20589
+ uploads.delete(uploadId);
20590
+ return "image too large (max 8MB base64 per image)";
20591
+ }
20592
+ up.chunks[seq] = data;
20593
+ up.received++;
20594
+ up.size += data.length;
20595
+ return null;
20596
+ }
20597
+ function claimImageRefs(uploads, raw) {
20598
+ if (raw === void 0)
20599
+ return [];
20600
+ if (!Array.isArray(raw))
20601
+ return { error: "imageRefs must be an array" };
20602
+ if (raw.length > MAX_IMAGES_PER_MESSAGE)
20603
+ return { error: `too many images (max ${MAX_IMAGES_PER_MESSAGE} per message)` };
20604
+ const images = [];
20605
+ for (const ref of raw) {
20606
+ if (typeof ref !== "string")
20607
+ return { error: "imageRefs entries must be strings" };
20608
+ const up = uploads.get(ref);
20609
+ if (!up)
20610
+ return { error: `unknown uploadId: ${ref}` };
20611
+ if (up.received !== up.chunks.length)
20612
+ return { error: `upload ${ref} incomplete (${up.received}/${up.chunks.length} chunks)` };
20613
+ images.push({ data: up.chunks.join(""), mimeType: up.mimeType });
20614
+ }
20615
+ for (const ref of raw)
20616
+ uploads.delete(ref);
20617
+ return images;
20618
+ }
20503
20619
  async function ensureSession(sm, workspace, sessionId) {
20504
20620
  if (sm.getSessionById(sessionId))
20505
20621
  return;
@@ -20638,6 +20754,7 @@ function setupAgentCoreWebSocket(deps) {
20638
20754
  ready ??= ensureSession(sm, userWs, sessionId).then(() => attachListener());
20639
20755
  return ready;
20640
20756
  };
20757
+ const uploads = /* @__PURE__ */ new Map();
20641
20758
  ws.on("message", (raw) => {
20642
20759
  let msg;
20643
20760
  try {
@@ -20663,8 +20780,20 @@ function setupAgentCoreWebSocket(deps) {
20663
20780
  });
20664
20781
  return;
20665
20782
  }
20783
+ if (msg.type === "image_chunk") {
20784
+ const err = acceptChunk(uploads, msg);
20785
+ if (err)
20786
+ send({ type: "error", error: err });
20787
+ return;
20788
+ }
20666
20789
  const text5 = typeof msg.inputText === "string" ? msg.inputText.trim() : "";
20667
- if (!text5) {
20790
+ const claimed = claimImageRefs(uploads, msg.imageRefs);
20791
+ if (!Array.isArray(claimed)) {
20792
+ send({ type: "error", error: claimed.error });
20793
+ return;
20794
+ }
20795
+ const images = claimed;
20796
+ if (!text5 && images.length === 0) {
20668
20797
  return;
20669
20798
  }
20670
20799
  if (text5.startsWith("/")) {
@@ -20722,10 +20851,29 @@ function setupAgentCoreWebSocket(deps) {
20722
20851
  void (async () => {
20723
20852
  try {
20724
20853
  await ensureOnce();
20725
- sm.appendUserMessage(sessionId, text5);
20854
+ const notes = [];
20855
+ for (const img of images) {
20856
+ try {
20857
+ const savedPath = await saveInboundMedia({
20858
+ workspacePath: userWs,
20859
+ accountId: sanitizeRuntimeId(runtimeSessionId),
20860
+ channel: "agentcore",
20861
+ buffer: Buffer.from(img.data, "base64"),
20862
+ kind: "image",
20863
+ mimeType: img.mimeType
20864
+ });
20865
+ notes.push(`[\u56FE\u7247\u5DF2\u4FDD\u5B58: ${savedPath}]`);
20866
+ } catch (err) {
20867
+ console.log(`[AgentCore] image save failed: ${err instanceof Error ? err.message : String(err)}`);
20868
+ notes.push("[\u56FE\u7247]");
20869
+ }
20870
+ }
20871
+ const fullText = notes.length > 0 ? text5 ? `${text5}
20872
+ ${notes.join("\n")}` : notes.join("\n") : text5;
20873
+ sm.appendUserMessage(sessionId, fullText);
20726
20874
  const result = await sm.sendUserMessage(sessionId, `[channel: agentcore]
20727
20875
 
20728
- ${text5}`);
20876
+ ${fullText}`, images.length > 0 ? images : void 0);
20729
20877
  if (result === "queued")
20730
20878
  send({ type: "queued" });
20731
20879
  } catch (err) {
@@ -20736,19 +20884,25 @@ ${text5}`);
20736
20884
  ws.on("close", () => {
20737
20885
  unsubscribe?.();
20738
20886
  unsubscribe = null;
20887
+ uploads.clear();
20739
20888
  activeOverrides.delete(runtimeSessionId);
20740
20889
  });
20741
20890
  console.log(`[AgentCore] ws connected \u2014 session ${sessionId}`);
20742
20891
  });
20743
20892
  }
20744
- var SESSION_HEADER, activeOverrides;
20893
+ var SESSION_HEADER, MAX_IMAGES_PER_MESSAGE, MAX_IMAGE_BASE64_LENGTH, MAX_PENDING_UPLOADS, MAX_CHUNKS_PER_UPLOAD, activeOverrides;
20745
20894
  var init_agentcore = __esm({
20746
20895
  "packages/server/dist/routes/agentcore.js"() {
20747
20896
  "use strict";
20748
20897
  init_commands2();
20749
20898
  init_ui_log_builder();
20750
20899
  init_session_types();
20900
+ init_media_store();
20751
20901
  SESSION_HEADER = "x-amzn-bedrock-agentcore-runtime-session-id";
20902
+ MAX_IMAGES_PER_MESSAGE = 4;
20903
+ MAX_IMAGE_BASE64_LENGTH = 8 * 1024 * 1024;
20904
+ MAX_PENDING_UPLOADS = 4;
20905
+ MAX_CHUNKS_PER_UPLOAD = 256;
20752
20906
  activeOverrides = /* @__PURE__ */ new Map();
20753
20907
  }
20754
20908
  });
@@ -21453,6 +21607,8 @@ function setupWebSocketHandler(deps) {
21453
21607
  const state = getState(client);
21454
21608
  if (!state)
21455
21609
  return;
21610
+ if (!client.sessionManager.isUIStateDirty(client.sessionId))
21611
+ return;
21456
21612
  const projectPath = resolveProjectPath2(client.projectId);
21457
21613
  const snapshot = createSaveSnapshot(state);
21458
21614
  if (snapshot.length === 0)
@@ -22065,6 +22221,9 @@ function createCronDb(globalDir2) {
22065
22221
  if (!cols.some((c) => c.name === "run_at")) {
22066
22222
  sqlite.exec(`ALTER TABLE cron_jobs ADD COLUMN run_at INTEGER`);
22067
22223
  }
22224
+ if (!cols.some((c) => c.name === "timeout_sec")) {
22225
+ sqlite.exec(`ALTER TABLE cron_jobs ADD COLUMN timeout_sec INTEGER`);
22226
+ }
22068
22227
  const runCols = sqlite.prepare(`PRAGMA table_info(cron_runs)`).all();
22069
22228
  if (!runCols.some((c) => c.name === "pid")) {
22070
22229
  sqlite.exec(`ALTER TABLE cron_runs ADD COLUMN pid INTEGER`);
@@ -22105,6 +22264,10 @@ var init_cron_db = __esm({
22105
22264
  runAt: integer4("run_at"),
22106
22265
  /** IANA timezone name; null = host timezone. */
22107
22266
  timezone: text4("timezone"),
22267
+ /** Max seconds a single cron-fired cli may run before the runner kills
22268
+ * it. NULL = default 3600 (runner.ts CLI_TIMEOUT_SEC). Range 60–21600,
22269
+ * enforced at the write points (REST routes / skill helper). */
22270
+ timeoutSec: integer4("timeout_sec"),
22108
22271
  /** JSON array of `{channelType, accountId}` records. Cron output (final
22109
22272
  * assistant text) is dispatched to each one. Empty array = log only. */
22110
22273
  targets: text4("targets").notNull().default("[]"),
@@ -22154,6 +22317,7 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
22154
22317
  schedule TEXT NOT NULL,
22155
22318
  run_at INTEGER,
22156
22319
  timezone TEXT,
22320
+ timeout_sec INTEGER,
22157
22321
  targets TEXT NOT NULL DEFAULT '[]',
22158
22322
  enabled INTEGER NOT NULL DEFAULT 1,
22159
22323
  last_run_status TEXT,
@@ -22587,6 +22751,7 @@ async function runJob(jobId, triggerKind) {
22587
22751
  const sessionId = `cron-${job.id}`;
22588
22752
  const args = ["cli", "-a", job.agentId, "-s", sessionId, "-w", job.workspacePath];
22589
22753
  const [spawnBin, spawnArgs] = process.platform === "win32" && cli.endsWith(".cmd") ? ["cmd.exe", ["/c", cli, ...args]] : [cli, args];
22754
+ const timeoutSec = job.timeoutSec ?? CLI_TIMEOUT_SEC;
22590
22755
  const teeStream = fs48.createWriteStream(logPath, { flags: "a" });
22591
22756
  teeStream.write(`=== ${(/* @__PURE__ */ new Date()).toISOString()} cron run ${runId} job=${jobId} trigger=${triggerKind} ===
22592
22757
  `);
@@ -22617,7 +22782,7 @@ async function runJob(jobId, triggerKind) {
22617
22782
  const killTimer = setTimeout(() => {
22618
22783
  timedOut = true;
22619
22784
  teeStream.write(`
22620
- [runner] timeout ${CLI_TIMEOUT_SEC}s \u2014 SIGTERM, grace ${KILL_GRACE_SEC}s
22785
+ [runner] timeout ${timeoutSec}s \u2014 SIGTERM, grace ${KILL_GRACE_SEC}s
22621
22786
  `);
22622
22787
  try {
22623
22788
  child.kill("SIGTERM");
@@ -22629,7 +22794,7 @@ async function runJob(jobId, triggerKind) {
22629
22794
  if (child.pid)
22630
22795
  killTreeHard(child.pid);
22631
22796
  }, KILL_GRACE_SEC * 1e3);
22632
- }, CLI_TIMEOUT_SEC * 1e3);
22797
+ }, timeoutSec * 1e3);
22633
22798
  child.stdout?.on("data", (chunk) => {
22634
22799
  stdout += chunk.toString("utf-8");
22635
22800
  teeStream.write(chunk);
@@ -22665,7 +22830,7 @@ async function runJob(jobId, triggerKind) {
22665
22830
  let failureReason = null;
22666
22831
  if (result.exitCode === 124) {
22667
22832
  status = "timeout";
22668
- failureReason = `cli exceeded ${CLI_TIMEOUT_SEC}s timeout`;
22833
+ failureReason = `cli exceeded ${timeoutSec}s timeout`;
22669
22834
  } else if (result.exitCode !== 0) {
22670
22835
  status = "failed";
22671
22836
  failureReason = stderr.slice(-500).trim() || `cli exited ${result.exitCode}`;
@@ -22813,6 +22978,15 @@ function validateSchedule(expr) {
22813
22978
  return err instanceof Error ? err.message : String(err);
22814
22979
  }
22815
22980
  }
22981
+ function validateTimeoutSec(v) {
22982
+ if (typeof v !== "number" || !Number.isInteger(v)) {
22983
+ return "timeoutSec must be an integer (seconds)";
22984
+ }
22985
+ if (v < TIMEOUT_SEC_MIN || v > TIMEOUT_SEC_MAX) {
22986
+ return `timeoutSec must be between ${TIMEOUT_SEC_MIN} and ${TIMEOUT_SEC_MAX}`;
22987
+ }
22988
+ return null;
22989
+ }
22816
22990
  function createCronRoutes() {
22817
22991
  const router = new Hono14();
22818
22992
  router.get("/cron/jobs", async (c) => {
@@ -22856,6 +23030,11 @@ function createCronRoutes() {
22856
23030
  if (hasRunAt && body.runAt <= Date.now()) {
22857
23031
  return c.json({ error: "runAt must be in the future" }, 400);
22858
23032
  }
23033
+ if (body.timeoutSec !== void 0 && body.timeoutSec !== null) {
23034
+ const err = validateTimeoutSec(body.timeoutSec);
23035
+ if (err)
23036
+ return c.json({ error: err }, 400);
23037
+ }
22859
23038
  const id = newJobId();
22860
23039
  const now = Date.now();
22861
23040
  getCronDb().insert(cronJobs).values({
@@ -22867,6 +23046,7 @@ function createCronRoutes() {
22867
23046
  schedule: hasSchedule ? body.schedule : "",
22868
23047
  runAt: hasRunAt ? body.runAt : null,
22869
23048
  timezone: body.timezone ?? null,
23049
+ timeoutSec: body.timeoutSec ?? null,
22870
23050
  targets: JSON.stringify(body.targets ?? []),
22871
23051
  enabled: body.enabled === false ? 0 : 1,
22872
23052
  lastRunStatus: null,
@@ -22903,6 +23083,11 @@ function createCronRoutes() {
22903
23083
  if (body.runAt <= Date.now())
22904
23084
  return c.json({ error: "runAt must be in the future" }, 400);
22905
23085
  }
23086
+ if (body.timeoutSec !== void 0 && body.timeoutSec !== null) {
23087
+ const err = validateTimeoutSec(body.timeoutSec);
23088
+ if (err)
23089
+ return c.json({ error: err }, 400);
23090
+ }
22906
23091
  const hasSchedule = typeof body.schedule === "string" && body.schedule.trim().length > 0;
22907
23092
  const hasRunAt = typeof body.runAt === "number";
22908
23093
  if (hasSchedule && hasRunAt) {
@@ -22930,6 +23115,8 @@ function createCronRoutes() {
22930
23115
  patch.runAt = nextRunAt;
22931
23116
  if (body.timezone !== void 0)
22932
23117
  patch.timezone = body.timezone || null;
23118
+ if (body.timeoutSec !== void 0)
23119
+ patch.timeoutSec = body.timeoutSec;
22933
23120
  if (body.targets !== void 0)
22934
23121
  patch.targets = JSON.stringify(body.targets);
22935
23122
  if (body.enabled !== void 0)
@@ -23045,6 +23232,7 @@ function computeNextRun(j) {
23045
23232
  return null;
23046
23233
  }
23047
23234
  }
23235
+ var TIMEOUT_SEC_MIN, TIMEOUT_SEC_MAX;
23048
23236
  var init_cron = __esm({
23049
23237
  "packages/server/dist/routes/cron.js"() {
23050
23238
  "use strict";
@@ -23052,6 +23240,8 @@ var init_cron = __esm({
23052
23240
  init_runner();
23053
23241
  init_dispatcher();
23054
23242
  init_broadcast();
23243
+ TIMEOUT_SEC_MIN = 60;
23244
+ TIMEOUT_SEC_MAX = 21600;
23055
23245
  }
23056
23246
  });
23057
23247
 
@@ -28954,7 +29144,7 @@ var init_dist2 = __esm({
28954
29144
  init_git_ssh();
28955
29145
  __dirname3 = path64.dirname(fileURLToPath3(import.meta.url));
28956
29146
  PORT = config.server.port;
28957
- HALO_VERSION = "1.1.2";
29147
+ HALO_VERSION = "1.1.4";
28958
29148
  PROJECT_ROOT = findProjectRoot();
28959
29149
  HALO_HOME4 = path64.join(homedir24(), ".halo");
28960
29150
  GIT_SHA = readGitSha();
@@ -29856,7 +30046,7 @@ import path65 from "node:path";
29856
30046
  import fs58 from "node:fs";
29857
30047
  import { homedir as homedir25 } from "node:os";
29858
30048
  import { parseArgs as parseArgs2 } from "node:util";
29859
- var VERSION = "1.1.2";
30049
+ var VERSION = "1.1.4";
29860
30050
  var HELP_TOP = `Usage: halo <command> [options]
29861
30051
 
29862
30052
  Commands:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turmind/halo",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Halo — multi-agent collaborative workspace. CLI + TUI + HTTP/WS server in one package.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@ skills:
45
45
  - send-file
46
46
  - self
47
47
  - aws-knowledge
48
- - nova-web-search
48
+ - web-search
49
49
  - cron
50
50
  - acp
51
51
  - halo
@@ -35,6 +35,34 @@ secrets:
35
35
  # thinking.default → effort selected when Thinking is on (adaptive)
36
36
  # thinking.defaultBudgetTokens → budget selected when Thinking is on (manual)
37
37
  models:
38
+ - id: global.anthropic.claude-fable-5-1
39
+ displayName: Claude Fable 5.1
40
+ maxOutputTokens: 128000
41
+ contextWindow: 1000000
42
+ capabilities:
43
+ image: true
44
+ video: false
45
+ audio: false
46
+ promptCaching:
47
+ default: 1h
48
+ ttlPresets:
49
+ - { value: 5m, label: 5min }
50
+ - { value: 1h, label: 1hour }
51
+ thinking:
52
+ # Fable 5.1 — Mythos-class flagship refresh. Adaptive effort set
53
+ # confirmed by an invalid-value probe against Bedrock (2026-09-02):
54
+ # low / medium / high / xhigh / max, same as Fable 5. 1M context,
55
+ # 128K output. Smoke-tested on invoke (xhigh).
56
+ mode: adaptive
57
+ defaultEnabled: true
58
+ default: high # flagship-of-flagships — high by default; xhigh/max on demand
59
+ effortPresets:
60
+ - { value: low, label: Low }
61
+ - { value: medium, label: Medium }
62
+ - { value: high, label: High }
63
+ - { value: xhigh, label: XHigh }
64
+ - { value: max, label: Max }
65
+
38
66
  - id: global.anthropic.claude-fable-5
39
67
  displayName: Claude Fable 5
40
68
  maxOutputTokens: 128000
@@ -53,6 +53,7 @@ Sensible defaults if user didn't say:
53
53
  | timezone | leave unset (host time) |
54
54
  | targets | none (log only — the run shows in admin Cron tab) |
55
55
  | label | summarize from prompt + schedule |
56
+ | timeout | leave unset (3600s) — see `--timeout-sec` below |
56
57
 
57
58
  ### 2. Translate the schedule
58
59
 
@@ -78,6 +79,13 @@ today" or "send the report next Monday morning".
78
79
 
79
80
  Ambiguous time ("morning")? Ask once for the exact hour.
80
81
 
82
+ **Max run time (`--timeout-sec`, optional)** — each fire runs as a cli child
83
+ that the server kills after `timeout_sec` seconds (integer, 60–21600). Unset
84
+ = default 3600. Only set it when the user says so: a quick push job that
85
+ should fail fast ("kill it after 5 minutes" → `--timeout-sec 300`), or a
86
+ heavy analysis run that needs more than an hour (`--timeout-sec 7200`). On
87
+ `update`, pass `--timeout-sec ""` to clear back to the default.
88
+
81
89
  ### 3. Channels (only when delivery is wanted)
82
90
 
83
91
  Run `channels` first to see what's available: