wayang-ai 0.6.2 → 0.7.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 (2) hide show
  1. package/dist/cli.js +2202 -703
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -192,9 +192,9 @@ var RpcClient = class {
192
192
  return Promise.reject(new Error("RPC transport closed: cannot send request"));
193
193
  }
194
194
  const id = this.nextId++;
195
- return new Promise((resolve11, reject) => {
195
+ return new Promise((resolve12, reject) => {
196
196
  this.pending.set(id, {
197
- resolve: (v) => resolve11(v),
197
+ resolve: (v) => resolve12(v),
198
198
  reject
199
199
  });
200
200
  this.transport.send({ id, method, params });
@@ -398,8 +398,8 @@ async function connectRemoteServer(options) {
398
398
  child.stderr?.on("data", (chunk) => {
399
399
  stderrTail = (stderrTail + chunk).slice(-4e3);
400
400
  });
401
- const exited = new Promise((resolve11) => {
402
- child.on("exit", (code) => resolve11(code));
401
+ const exited = new Promise((resolve12) => {
402
+ child.on("exit", (code) => resolve12(code));
403
403
  });
404
404
  const transport = createStreamTransport(child.stdout, child.stdin);
405
405
  const rpc = new RpcClient(transport);
@@ -418,10 +418,10 @@ async function connectRemoteServer(options) {
418
418
  }
419
419
  const exitOrKill = Promise.race([
420
420
  exited,
421
- new Promise((resolve11) => {
421
+ new Promise((resolve12) => {
422
422
  setTimeout(() => {
423
423
  child.kill("SIGKILL");
424
- resolve11(null);
424
+ resolve12(null);
425
425
  }, SHUTDOWN_GRACE_MS);
426
426
  })
427
427
  ]);
@@ -439,7 +439,7 @@ ${stderrTail}`);
439
439
  return { remote, stop };
440
440
  }
441
441
  function waitForReady(child, rpc, exited, getStderrTail, timeoutMs) {
442
- return new Promise((resolve11, reject) => {
442
+ return new Promise((resolve12, reject) => {
443
443
  let settled = false;
444
444
  const finish = (err) => {
445
445
  if (settled) return;
@@ -447,7 +447,7 @@ function waitForReady(child, rpc, exited, getStderrTail, timeoutMs) {
447
447
  clearTimeout(timer);
448
448
  offReady();
449
449
  if (err) reject(err);
450
- else resolve11();
450
+ else resolve12();
451
451
  };
452
452
  const timer = setTimeout(() => {
453
453
  child.kill("SIGKILL");
@@ -604,7 +604,7 @@ import React6 from "react";
604
604
  import { render } from "ink";
605
605
 
606
606
  // src/tui/app.tsx
607
- import { useRef as useRef2 } from "react";
607
+ import { useRef as useRef3 } from "react";
608
608
 
609
609
  // src/tui/hooks/use-memoized-fn.ts
610
610
  import { useRef, useCallback } from "react";
@@ -1104,14 +1104,17 @@ function summarizeWorkerMessage(argsJson, width, result, maxLines = 3) {
1104
1104
  const message = args ? str(args.message) ?? "" : argsJson;
1105
1105
  const { lines, hidden } = previewLines(message, maxLines, width);
1106
1106
  const failed = result !== void 0 && (result.isError === true || /^\[ERROR\]/.test(result.text));
1107
+ let delivery;
1108
+ if (result !== void 0 && !failed) {
1109
+ delivery = /\bqueued\b/i.test(result.text) ? "queued" : /\bdelivered\b/i.test(result.text) ? "delivered" : void 0;
1110
+ }
1107
1111
  return {
1108
1112
  ...workerId && { workerId },
1109
1113
  lines,
1110
1114
  hiddenLines: hidden,
1111
1115
  outcome: result === void 0 ? "pending" : failed ? "failed" : "ok",
1112
1116
  failure: failed ? failureText(result.text, width) : "",
1113
- ...result !== void 0 && !failed && /\bqueued\b/.test(result.text) && { delivery: "queued" },
1114
- ...result !== void 0 && !failed && /\bdelivered\b/.test(result.text) && { delivery: "delivered" }
1117
+ ...delivery && { delivery }
1115
1118
  };
1116
1119
  }
1117
1120
  var TASK_ID_RE = /^Task (\S+) (?:added|assigned)/;
@@ -1369,6 +1372,7 @@ function WorkerMessageRow({ section: section2, result, lookups }) {
1369
1372
  const worker = summary.workerId ? lookups.workers.get(summary.workerId) : void 0;
1370
1373
  const target = formatWorkerTag(worker ?? { workerId: summary.workerId }) || "(\u672A\u77E5 worker)";
1371
1374
  const iconColor = summary.outcome === "failed" ? theme.action.failColor : theme.action.color;
1375
+ const delivered = summary.delivery === "queued" ? "\u5DF2\u6392\u961F" : summary.delivery === "delivered" ? "\u5DF2\u9001\u8FBE" : "\u5DF2\u53D1\u9001";
1372
1376
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
1373
1377
  /* @__PURE__ */ jsxs2(Box2, { children: [
1374
1378
  /* @__PURE__ */ jsxs2(Text2, { color: iconColor, bold: true, children: [
@@ -1379,7 +1383,7 @@ function WorkerMessageRow({ section: section2, result, lookups }) {
1379
1383
  " ",
1380
1384
  target
1381
1385
  ] }),
1382
- /* @__PURE__ */ jsx3(OutcomeMark, { outcome: summary.outcome, ok: summary.delivery === "queued" ? "\u5DF2\u6392\u961F" : "\u5DF2\u9001\u8FBE", failed: "\u672A\u9001\u8FBE" })
1386
+ /* @__PURE__ */ jsx3(OutcomeMark, { outcome: summary.outcome, ok: delivered, failed: "\u672A\u9001\u8FBE" })
1383
1387
  ] }),
1384
1388
  /* @__PURE__ */ jsx3(PreviewLines, { lines: summary.lines, hidden: summary.hiddenLines }),
1385
1389
  summary.failure && /* @__PURE__ */ jsx3(SecondaryLine, { color: theme.action.failColor, children: summary.failure })
@@ -1651,7 +1655,7 @@ var ChatArea = React2.memo(function ChatArea2({ items, busy, streamingItems, con
1651
1655
  });
1652
1656
 
1653
1657
  // src/tui/components/input-area.tsx
1654
- import { useState as useState2, useMemo as useMemo2 } from "react";
1658
+ import { useState as useState2, useMemo as useMemo2, useRef as useRef2 } from "react";
1655
1659
 
1656
1660
  // src/tui/utils/format-tokens.ts
1657
1661
  function formatTokenCount(tokens) {
@@ -1798,21 +1802,302 @@ function useSlashCommands(supervisor) {
1798
1802
 
1799
1803
  // src/tui/components/input-area.tsx
1800
1804
  import { Box as Box6, Text as Text7, useInput } from "ink";
1801
- import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
1802
- function InputArea({ onSubmit, onExit, onEscape, busy }) {
1805
+
1806
+ // src/tui/utils/input-display.ts
1807
+ function normalizeInputText(s) {
1808
+ return s.replace(/\r\n?/g, "\n");
1809
+ }
1810
+ function isHighSurrogate(codeUnit) {
1811
+ return codeUnit >= 55296 && codeUnit <= 56319;
1812
+ }
1813
+ function isLowSurrogate(codeUnit) {
1814
+ return codeUnit >= 56320 && codeUnit <= 57343;
1815
+ }
1816
+ function prevCodePointLength(s, index) {
1817
+ if (index <= 0) return 0;
1818
+ if (index >= 2 && isHighSurrogate(s.charCodeAt(index - 2)) && isLowSurrogate(s.charCodeAt(index - 1))) {
1819
+ return 2;
1820
+ }
1821
+ return 1;
1822
+ }
1823
+ function nextCodePointLength(s, index) {
1824
+ if (index < 0 || index >= s.length) return 0;
1825
+ if (index + 1 < s.length && isHighSurrogate(s.charCodeAt(index)) && isLowSurrogate(s.charCodeAt(index + 1))) {
1826
+ return 2;
1827
+ }
1828
+ return 1;
1829
+ }
1830
+ function wrapByDisplayWidth(line, maxWidth) {
1831
+ const limit = Math.max(1, Math.floor(maxWidth));
1832
+ if (stringWidth(line) <= limit) return [line];
1833
+ const segments = [];
1834
+ let current = "";
1835
+ let width = 0;
1836
+ for (const ch of line) {
1837
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
1838
+ if (current !== "" && width + cw > limit) {
1839
+ segments.push(current);
1840
+ current = ch;
1841
+ width = cw;
1842
+ } else {
1843
+ current += ch;
1844
+ width += cw;
1845
+ }
1846
+ }
1847
+ if (current !== "") segments.push(current);
1848
+ return segments.length > 0 ? segments : [""];
1849
+ }
1850
+ function splitLineBySelection(line, selection) {
1851
+ const lo = line.offset;
1852
+ const hi = line.offset + line.text.length;
1853
+ const s = Math.max(lo, Math.min(hi, selection.start));
1854
+ const e = Math.max(lo, Math.min(hi, selection.end));
1855
+ if (s >= e) return [{ text: line.text, selected: false }];
1856
+ const out = [];
1857
+ if (s > lo) out.push({ text: line.text.slice(0, s - lo), selected: false });
1858
+ out.push({ text: line.text.slice(s - lo, e - lo), selected: true });
1859
+ if (e < hi) out.push({ text: line.text.slice(e - lo), selected: false });
1860
+ return out.filter((seg) => seg.text.length > 0);
1861
+ }
1862
+ function layoutInput(value, cursor, maxWidth) {
1863
+ const c = Math.max(0, Math.min(value.length, cursor));
1864
+ const logicalLines = value.split("\n");
1865
+ const lines = [];
1866
+ let cursorLine = 0;
1867
+ let cursorCol = 0;
1868
+ let offset = 0;
1869
+ for (const lineText of logicalLines) {
1870
+ const col = c - offset;
1871
+ const ownsCursor = col >= 0 && col <= lineText.length;
1872
+ const segments = wrapByDisplayWidth(lineText, maxWidth);
1873
+ for (const segment of segments) {
1874
+ lines.push({ text: segment, offset });
1875
+ offset += segment.length;
1876
+ }
1877
+ if (ownsCursor) {
1878
+ let base = 0;
1879
+ for (let i = 0; i < segments.length; i++) {
1880
+ const segment = segments[i];
1881
+ const segEnd = base + segment.length;
1882
+ const isLastSegment = i === segments.length - 1;
1883
+ if (col < segEnd || col === segEnd && isLastSegment) {
1884
+ cursorLine = lines.length - segments.length + i;
1885
+ cursorCol = col - base;
1886
+ break;
1887
+ }
1888
+ base = segEnd;
1889
+ }
1890
+ }
1891
+ offset += 1;
1892
+ }
1893
+ return { lines, cursorLine, cursorCol };
1894
+ }
1895
+
1896
+ // src/tui/utils/input-editing.ts
1897
+ function clampIndex(value, index) {
1898
+ return Math.max(0, Math.min(value.length, index));
1899
+ }
1900
+ function normalizeCursorIndex(value, index) {
1901
+ const i = clampIndex(value, index);
1902
+ if (i > 0 && i < value.length && (value.codePointAt(i - 1) ?? 0) > 65535) {
1903
+ return i + 1;
1904
+ }
1905
+ return i;
1906
+ }
1907
+ var CJK_RANGES = [
1908
+ // Hiragana + Katakana, incl. the prolonged sound mark ー (the middle dot ・
1909
+ // is punctuation and filtered out before this check).
1910
+ [12352, 12543],
1911
+ // CJK Extension A + Unified Ideographs.
1912
+ [13312, 40959],
1913
+ // CJK compatibility ideographs.
1914
+ [63744, 64255],
1915
+ // CJK Extension B and beyond.
1916
+ [131072, 262143]
1917
+ ];
1918
+ var WHITESPACE = /\s/u;
1919
+ var PUNCTUATION = new RegExp("\\p{P}", "u");
1920
+ function classifyCodePoint(cp) {
1921
+ if (cp === 95) return "word";
1922
+ const ch = String.fromCodePoint(cp);
1923
+ if (WHITESPACE.test(ch)) return "separator";
1924
+ if (PUNCTUATION.test(ch)) return "separator";
1925
+ for (const [lo, hi] of CJK_RANGES) {
1926
+ if (cp >= lo && cp <= hi) return "cjk";
1927
+ }
1928
+ return "word";
1929
+ }
1930
+ function classBefore(value, index) {
1931
+ if (index <= 0) return "separator";
1932
+ const len = prevCodePointLength(value, index);
1933
+ return classifyCodePoint(value.codePointAt(index - len) ?? 0);
1934
+ }
1935
+ function classAt(value, index) {
1936
+ return classifyCodePoint(value.codePointAt(index) ?? 0);
1937
+ }
1938
+ function wordLeft(value, index) {
1939
+ let i = normalizeCursorIndex(value, index);
1940
+ while (i > 0 && classBefore(value, i) === "separator") {
1941
+ i -= prevCodePointLength(value, i);
1942
+ }
1943
+ if (i === 0) return 0;
1944
+ if (classBefore(value, i) === "cjk") {
1945
+ return i - prevCodePointLength(value, i);
1946
+ }
1947
+ while (i > 0 && classBefore(value, i) === "word") {
1948
+ i -= prevCodePointLength(value, i);
1949
+ }
1950
+ return i;
1951
+ }
1952
+ function wordRight(value, index) {
1953
+ const len = value.length;
1954
+ let i = normalizeCursorIndex(value, index);
1955
+ while (i < len && classAt(value, i) === "separator") {
1956
+ i += nextCodePointLength(value, i);
1957
+ }
1958
+ if (i === len) return len;
1959
+ if (classAt(value, i) === "cjk") {
1960
+ return i + nextCodePointLength(value, i);
1961
+ }
1962
+ while (i < len && classAt(value, i) === "word") {
1963
+ i += nextCodePointLength(value, i);
1964
+ }
1965
+ return i;
1966
+ }
1967
+ function lineStart(value, index) {
1968
+ let i = normalizeCursorIndex(value, index);
1969
+ while (i > 0 && value.charCodeAt(i - 1) !== 10) i -= 1;
1970
+ return i;
1971
+ }
1972
+ function lineEnd(value, index) {
1973
+ let i = normalizeCursorIndex(value, index);
1974
+ while (i < value.length && value.charCodeAt(i) !== 10) i += 1;
1975
+ return i;
1976
+ }
1977
+ function killToLineEnd(value, index) {
1978
+ const cursor = normalizeCursorIndex(value, index);
1979
+ const end = lineEnd(value, cursor);
1980
+ return { value: value.slice(0, cursor) + value.slice(end), cursor };
1981
+ }
1982
+ function killToLineStart(value, index) {
1983
+ const cursor = normalizeCursorIndex(value, index);
1984
+ const start = lineStart(value, cursor);
1985
+ return { value: value.slice(0, start) + value.slice(cursor), cursor: start };
1986
+ }
1987
+ function killWordBefore(value, index) {
1988
+ const cursor = normalizeCursorIndex(value, index);
1989
+ const start = wordLeft(value, cursor);
1990
+ return { value: value.slice(0, start) + value.slice(cursor), cursor: start };
1991
+ }
1992
+ function normalizeSelection(value, anchor, cursor) {
1993
+ const c = normalizeCursorIndex(value, cursor);
1994
+ const a = anchor === null ? c : normalizeCursorIndex(value, anchor);
1995
+ return a <= c ? { start: a, end: c } : { start: c, end: a };
1996
+ }
1997
+ function replaceRange(value, start, end, insert) {
1998
+ let a = clampIndex(value, start);
1999
+ let b = clampIndex(value, end);
2000
+ if (a > b) [a, b] = [b, a];
2001
+ return { value: value.slice(0, a) + insert + value.slice(b), cursor: a + insert.length };
2002
+ }
2003
+
2004
+ // src/tui/utils/input-history.ts
2005
+ var MERGE_WINDOW_MS = 500;
2006
+ var MAX_ENTRIES = 100;
2007
+ function initInputHistory(value, ts = Date.now()) {
2008
+ return {
2009
+ entries: [{ value, cursor: value.length, anchor: null, ts, kind: "snap" }],
2010
+ pointer: 0,
2011
+ rewound: false
2012
+ };
2013
+ }
2014
+ function continuesRun(prev, edit) {
2015
+ if (prev.kind === "insert" && edit.kind === "insert") {
2016
+ const lenDiff = edit.value.length - prev.value.length;
2017
+ return (lenDiff === 1 || lenDiff === 2) && edit.cursor === prev.cursor + lenDiff;
2018
+ }
2019
+ if (prev.kind === "delete" && edit.kind === "delete") {
2020
+ const diff = prev.value.length - edit.value.length;
2021
+ return (diff === 1 || diff === 2) && edit.cursor === prev.cursor - diff;
2022
+ }
2023
+ return false;
2024
+ }
2025
+ function commitEdit(history, edit) {
2026
+ const { entries, pointer, rewound } = history;
2027
+ const prev = entries[pointer];
2028
+ const atTail = pointer === entries.length - 1;
2029
+ if (prev && atTail && !rewound && edit.ts - prev.ts < MERGE_WINDOW_MS && continuesRun(prev, edit)) {
2030
+ const merged = entries.slice();
2031
+ merged[pointer] = { ...edit };
2032
+ return { entries: merged, pointer, rewound: false };
2033
+ }
2034
+ const next = [...entries.slice(0, pointer + 1), { ...edit }];
2035
+ if (next.length > MAX_ENTRIES) {
2036
+ const trimmed = next.slice(next.length - MAX_ENTRIES);
2037
+ return { entries: trimmed, pointer: trimmed.length - 1, rewound: false };
2038
+ }
2039
+ return { entries: next, pointer: next.length - 1, rewound: false };
2040
+ }
2041
+ function undoEdit(history) {
2042
+ if (history.pointer === 0) return null;
2043
+ return { entries: history.entries, pointer: history.pointer - 1, rewound: true };
2044
+ }
2045
+ function redoEdit(history) {
2046
+ if (history.pointer === history.entries.length - 1) return null;
2047
+ return { entries: history.entries, pointer: history.pointer + 1, rewound: true };
2048
+ }
2049
+
2050
+ // src/tui/components/input-area.tsx
2051
+ import { Fragment as Fragment3, jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
2052
+ var PREFIX2 = `${theme.user.prefix} `;
2053
+ var PREFIX_WIDTH2 = stringWidth(PREFIX2);
2054
+ function InputArea({ onSubmit, onExit, onEscape, busy, width }) {
1803
2055
  const [input, setInput] = useState2("");
1804
2056
  const [cursor, setCursor] = useState2(0);
2057
+ const [anchor, setAnchor] = useState2(null);
1805
2058
  const [selectedIndex, setSelectedIndex] = useState2(0);
2059
+ const historyRef = useRef2(initInputHistory(""));
1806
2060
  const suggestions = useMemo2(() => {
1807
2061
  if (!input.startsWith("/") || input.includes(" ")) return [];
1808
2062
  const typed = input.slice(1).toLowerCase();
1809
2063
  return SLASH_COMMANDS.filter((c) => c.name.startsWith(typed));
1810
2064
  }, [input]);
1811
2065
  const showSuggestions = suggestions.length > 0;
2066
+ const sel = useMemo2(
2067
+ () => normalizeSelection(input, anchor, cursor),
2068
+ [input, anchor, cursor]
2069
+ );
2070
+ const hasSelection = sel.start < sel.end;
2071
+ const visibleSelection = hasSelection && input.slice(sel.start, sel.end).replace(/\n/g, "").length > 0;
1812
2072
  const clampedIndex = showSuggestions ? Math.min(selectedIndex, suggestions.length - 1) : 0;
1813
2073
  const reset = useMemoizedFn(() => {
1814
2074
  setInput("");
1815
2075
  setCursor(0);
2076
+ setAnchor(null);
2077
+ setSelectedIndex(0);
2078
+ historyRef.current = initInputHistory("");
2079
+ });
2080
+ const applyEdit = useMemoizedFn((r, kind) => {
2081
+ setInput(r.value);
2082
+ setCursor(r.cursor);
2083
+ setAnchor(null);
2084
+ setSelectedIndex(0);
2085
+ if (r.value === input && r.cursor === cursor) return;
2086
+ historyRef.current = commitEdit(historyRef.current, {
2087
+ value: r.value,
2088
+ cursor: r.cursor,
2089
+ anchor: null,
2090
+ ts: Date.now(),
2091
+ kind
2092
+ });
2093
+ });
2094
+ const applySnapshot = useMemoizedFn((history) => {
2095
+ const entry = history.entries[history.pointer];
2096
+ if (!entry) return;
2097
+ historyRef.current = history;
2098
+ setInput(entry.value);
2099
+ setCursor(entry.cursor);
2100
+ setAnchor(entry.anchor);
1816
2101
  setSelectedIndex(0);
1817
2102
  });
1818
2103
  const applySuggestion = useMemoizedFn((index) => {
@@ -1820,9 +2105,7 @@ function InputArea({ onSubmit, onExit, onEscape, busy }) {
1820
2105
  if (!cmd) return;
1821
2106
  const text = `/${cmd.name}`;
1822
2107
  const filled = "args" in cmd ? `${text} ` : text;
1823
- setInput(filled);
1824
- setCursor(filled.length);
1825
- setSelectedIndex(0);
2108
+ applyEdit(replaceRange(input, 0, input.length, filled), "replace");
1826
2109
  });
1827
2110
  useInput((ch, key) => {
1828
2111
  if (key.return) {
@@ -1872,39 +2155,133 @@ function InputArea({ onSubmit, onExit, onEscape, busy }) {
1872
2155
  return;
1873
2156
  }
1874
2157
  if (key.escape) {
2158
+ if (hasSelection) {
2159
+ setAnchor(null);
2160
+ return;
2161
+ }
1875
2162
  if (showSuggestions) {
1876
- setInput("");
1877
- setCursor(0);
1878
- setSelectedIndex(0);
2163
+ reset();
1879
2164
  } else {
1880
2165
  onEscape?.();
1881
2166
  reset();
1882
2167
  }
1883
2168
  return;
1884
2169
  }
2170
+ if (key.ctrl && ch === "a") {
2171
+ setAnchor(0);
2172
+ setCursor(input.length);
2173
+ return;
2174
+ }
2175
+ if (key.ctrl && ch === "e") {
2176
+ setAnchor(null);
2177
+ setCursor(lineEnd(input, cursor));
2178
+ return;
2179
+ }
2180
+ if (key.ctrl && ch === "k") {
2181
+ if (hasSelection) {
2182
+ applyEdit(replaceRange(input, sel.start, sel.end, ""), "replace");
2183
+ } else {
2184
+ applyEdit(killToLineEnd(input, cursor), "kill");
2185
+ }
2186
+ return;
2187
+ }
2188
+ if (key.ctrl && ch === "u") {
2189
+ if (hasSelection) {
2190
+ applyEdit(replaceRange(input, sel.start, sel.end, ""), "replace");
2191
+ } else {
2192
+ applyEdit(killToLineStart(input, cursor), "kill");
2193
+ }
2194
+ return;
2195
+ }
2196
+ if (key.ctrl && ch === "w") {
2197
+ if (hasSelection) {
2198
+ applyEdit(replaceRange(input, sel.start, sel.end, ""), "replace");
2199
+ } else {
2200
+ applyEdit(killWordBefore(input, cursor), "kill");
2201
+ }
2202
+ return;
2203
+ }
2204
+ const moveWordLeft = () => {
2205
+ if (key.shift) setAnchor((a) => a ?? cursor);
2206
+ else setAnchor(null);
2207
+ setCursor(wordLeft(input, cursor));
2208
+ };
2209
+ const moveWordRight = () => {
2210
+ if (key.shift) setAnchor((a) => a ?? cursor);
2211
+ else setAnchor(null);
2212
+ setCursor(wordRight(input, cursor));
2213
+ };
2214
+ if (key.ctrl && key.leftArrow) {
2215
+ moveWordLeft();
2216
+ return;
2217
+ }
2218
+ if (key.ctrl && key.rightArrow) {
2219
+ moveWordRight();
2220
+ return;
2221
+ }
2222
+ if (key.meta && !key.escape && ch === "b") {
2223
+ moveWordLeft();
2224
+ return;
2225
+ }
2226
+ if (key.meta && !key.escape && ch === "f") {
2227
+ moveWordRight();
2228
+ return;
2229
+ }
2230
+ if (key.ctrl && ch === "z") {
2231
+ const next = undoEdit(historyRef.current);
2232
+ if (next) applySnapshot(next);
2233
+ return;
2234
+ }
2235
+ if (key.ctrl && ch === "y") {
2236
+ const next = redoEdit(historyRef.current);
2237
+ if (next) applySnapshot(next);
2238
+ return;
2239
+ }
2240
+ if (key.shift && key.leftArrow) {
2241
+ setAnchor((a) => a ?? cursor);
2242
+ setCursor((c) => Math.max(0, c - prevCodePointLength(input, c)));
2243
+ return;
2244
+ }
2245
+ if (key.shift && key.rightArrow) {
2246
+ setAnchor((a) => a ?? cursor);
2247
+ setCursor((c) => Math.min(input.length, c + nextCodePointLength(input, c)));
2248
+ return;
2249
+ }
1885
2250
  if (key.backspace || key.delete) {
2251
+ if (hasSelection) {
2252
+ applyEdit(replaceRange(input, sel.start, sel.end, ""), "delete");
2253
+ return;
2254
+ }
1886
2255
  if (cursor === 0) return;
1887
- setInput((prev) => prev.slice(0, cursor - 1) + prev.slice(cursor));
1888
- setCursor((c) => Math.max(0, c - 1));
1889
- setSelectedIndex(0);
2256
+ const len = prevCodePointLength(input, cursor);
2257
+ applyEdit(
2258
+ { value: input.slice(0, cursor - len) + input.slice(cursor), cursor: cursor - len },
2259
+ "delete"
2260
+ );
1890
2261
  return;
1891
2262
  }
1892
2263
  if (key.leftArrow) {
1893
- setCursor((c) => Math.max(0, c - 1));
2264
+ setAnchor(null);
2265
+ setCursor((c) => Math.max(0, c - prevCodePointLength(input, c)));
1894
2266
  return;
1895
2267
  }
1896
2268
  if (key.rightArrow) {
1897
- setCursor((c) => Math.min(input.length, c + 1));
2269
+ setAnchor(null);
2270
+ setCursor((c) => Math.min(input.length, c + nextCodePointLength(input, c)));
1898
2271
  return;
1899
2272
  }
1900
2273
  if (ch && !key.ctrl && !key.meta) {
1901
- setInput((prev) => prev.slice(0, cursor) + ch + prev.slice(cursor));
1902
- setCursor((c) => c + ch.length);
1903
- setSelectedIndex(0);
2274
+ const text = normalizeInputText(ch);
2275
+ if (text.length === 0) return;
2276
+ const r = replaceRange(input, sel.start, sel.end, text);
2277
+ applyEdit(r, [...text].length === 1 ? "insert" : "paste");
1904
2278
  }
1905
2279
  });
1906
- const before = input.slice(0, cursor);
1907
- const after = input.slice(cursor);
2280
+ const textBudget = Math.max(1, width - 2 - PREFIX_WIDTH2);
2281
+ const layout = useMemo2(
2282
+ () => layoutInput(input, cursor, textBudget),
2283
+ [input, cursor, textBudget]
2284
+ );
1908
2285
  return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
1909
2286
  showSuggestions && /* @__PURE__ */ jsx9(Box6, { flexDirection: "column", paddingX: 1, marginBottom: 0, children: suggestions.map((cmd, i) => {
1910
2287
  const isSelected = i === clampedIndex;
@@ -1918,7 +2295,7 @@ function InputArea({ onSubmit, onExit, onEscape, busy }) {
1918
2295
  ] })
1919
2296
  ] }, cmd.name);
1920
2297
  }) }),
1921
- /* @__PURE__ */ jsxs6(
2298
+ /* @__PURE__ */ jsx9(
1922
2299
  Box6,
1923
2300
  {
1924
2301
  borderStyle: "single",
@@ -1926,16 +2303,26 @@ function InputArea({ onSubmit, onExit, onEscape, busy }) {
1926
2303
  borderLeft: false,
1927
2304
  borderRight: false,
1928
2305
  paddingX: 1,
1929
- children: [
1930
- /* @__PURE__ */ jsx9(Text7, { color: theme.user.prefixColor, bold: true, children: theme.user.prefix }),
1931
- /* @__PURE__ */ jsxs6(Text7, { children: [
1932
- " ",
1933
- before
1934
- ] }),
1935
- /* @__PURE__ */ jsx9(Text7, { color: "white", inverse: true, children: after.length > 0 ? after[0] : " " }),
1936
- /* @__PURE__ */ jsx9(Text7, { children: after.slice(1) }),
1937
- /* @__PURE__ */ jsx9(Text7, { dimColor: true, children: busy ? " (thinking...)" : "" })
1938
- ]
2306
+ children: /* @__PURE__ */ jsx9(Box6, { flexDirection: "column", children: layout.lines.map((line, i) => {
2307
+ const isCursorLine = i === layout.cursorLine;
2308
+ const col = layout.cursorCol;
2309
+ const charLen = isCursorLine ? nextCodePointLength(line.text, col) : 0;
2310
+ const before = isCursorLine ? line.text.slice(0, col) : line.text;
2311
+ const cursorCell = isCursorLine && charLen > 0 ? line.text.slice(col, col + charLen) : " ";
2312
+ const after = isCursorLine ? line.text.slice(col + charLen) : "";
2313
+ return /* @__PURE__ */ jsxs6(Box6, { children: [
2314
+ i === 0 ? /* @__PURE__ */ jsx9(Text7, { color: theme.user.prefixColor, bold: true, children: theme.user.prefix }) : /* @__PURE__ */ jsx9(Text7, { children: " ".repeat(PREFIX_WIDTH2) }),
2315
+ visibleSelection ? /* @__PURE__ */ jsxs6(Fragment3, { children: [
2316
+ i === 0 && /* @__PURE__ */ jsx9(Text7, { children: " " }),
2317
+ splitLineBySelection(line, sel).map((seg, j) => /* @__PURE__ */ jsx9(Text7, { inverse: seg.selected, children: seg.text }, j))
2318
+ ] }) : /* @__PURE__ */ jsxs6(Fragment3, { children: [
2319
+ /* @__PURE__ */ jsx9(Text7, { children: i === 0 ? ` ${before}` : before }),
2320
+ isCursorLine && /* @__PURE__ */ jsx9(Text7, { color: "white", inverse: true, children: cursorCell }),
2321
+ /* @__PURE__ */ jsx9(Text7, { children: after })
2322
+ ] }),
2323
+ isCursorLine && busy && /* @__PURE__ */ jsx9(Text7, { dimColor: true, children: " (thinking...)" })
2324
+ ] }, i);
2325
+ }) })
1939
2326
  }
1940
2327
  )
1941
2328
  ] });
@@ -1944,13 +2331,59 @@ function InputArea({ onSubmit, onExit, onEscape, busy }) {
1944
2331
  // src/tui/components/inquiry-prompt.tsx
1945
2332
  import React4, { useState as useState3 } from "react";
1946
2333
  import { Box as Box7, Text as Text8, useInput as useInput2 } from "ink";
2334
+
2335
+ // src/types/config.ts
2336
+ function validateConfig(config) {
2337
+ for (const [name, provider] of Object.entries(config.providers)) {
2338
+ if (!provider.endpoint) return `providers.${name}.endpoint is required`;
2339
+ if (!provider.apiKey) return `providers.${name}.apiKey is required (set WAYANG_LLM_API_KEY or configure in config file)`;
2340
+ if (!provider.modelName) return `providers.${name}.modelName is required`;
2341
+ const window = provider.contextWindowTokens;
2342
+ if (window !== void 0 && !(Number.isInteger(window) && window > 0)) {
2343
+ return `providers.${name}.contextWindowTokens must be a positive integer (got ${JSON.stringify(window)})`;
2344
+ }
2345
+ }
2346
+ const cp = config.controller.provider;
2347
+ if (!config.providers[cp]) return `controller.provider "${cp}" not found`;
2348
+ const wp = config.worker.provider;
2349
+ if (!config.providers[wp]) return `worker.provider "${wp}" not found`;
2350
+ return null;
2351
+ }
2352
+
2353
+ // src/types/signal.ts
2354
+ function getSignalField(sig, field) {
2355
+ return sig.payload[field];
2356
+ }
2357
+
2358
+ // src/types/inquiry.ts
2359
+ var INQUIRE_OTHER_OPTION = "\u5176\u4ED6\uFF08\u81EA\u884C\u8F93\u5165\uFF09";
2360
+ var INQUIRE_CUSTOM_ANSWER_PREFIX = "[Other] ";
2361
+ function buildSelectChoices(options, allowOther) {
2362
+ return allowOther === false ? options : [...options, INQUIRE_OTHER_OPTION];
2363
+ }
2364
+ function isOtherChoice(choice) {
2365
+ return choice === INQUIRE_OTHER_OPTION;
2366
+ }
2367
+ function formatCustomAnswer(text) {
2368
+ return `${INQUIRE_CUSTOM_ANSWER_PREFIX}${text}`;
2369
+ }
2370
+
2371
+ // src/tui/components/inquiry-prompt.tsx
1947
2372
  import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
1948
2373
  function InquiryPrompt({ inquiry, onAnswer }) {
1949
2374
  if (inquiry.type === "confirm") {
1950
2375
  return /* @__PURE__ */ jsx10(ConfirmPrompt, { message: inquiry.message, defaultVal: inquiry.default, onAnswer });
1951
2376
  }
1952
2377
  if (inquiry.type === "select") {
1953
- return /* @__PURE__ */ jsx10(SelectPrompt, { message: inquiry.message, options: inquiry.options ?? [], onAnswer });
2378
+ return /* @__PURE__ */ jsx10(
2379
+ SelectPrompt,
2380
+ {
2381
+ message: inquiry.message,
2382
+ options: inquiry.options ?? [],
2383
+ allowOther: inquiry.allowOther,
2384
+ onAnswer
2385
+ }
2386
+ );
1954
2387
  }
1955
2388
  return /* @__PURE__ */ jsx10(TextPrompt, { message: inquiry.message, onAnswer });
1956
2389
  }
@@ -1974,25 +2407,53 @@ function ConfirmPrompt({ message, defaultVal, onAnswer }) {
1974
2407
  ] }, opt)) })
1975
2408
  ] });
1976
2409
  }
1977
- function SelectPrompt({ message, options, onAnswer }) {
2410
+ function SelectPrompt({ message, options, allowOther, onAnswer }) {
2411
+ const [awaitingCustom, setAwaitingCustom] = useState3(false);
2412
+ if (awaitingCustom) {
2413
+ return /* @__PURE__ */ jsx10(
2414
+ TextPrompt,
2415
+ {
2416
+ message,
2417
+ inputLabel: INQUIRE_OTHER_OPTION,
2418
+ onAnswer: (text) => onAnswer(formatCustomAnswer(text))
2419
+ }
2420
+ );
2421
+ }
2422
+ return /* @__PURE__ */ jsx10(
2423
+ SelectList,
2424
+ {
2425
+ message,
2426
+ options,
2427
+ allowOther,
2428
+ onAnswer,
2429
+ onRequestCustom: () => setAwaitingCustom(true)
2430
+ }
2431
+ );
2432
+ }
2433
+ function SelectList({ message, options, allowOther, onAnswer, onRequestCustom }) {
2434
+ const choices = buildSelectChoices(options, allowOther);
1978
2435
  const [selected, setSelected] = useState3(0);
1979
2436
  useInput2((_ch, key) => {
1980
- if (key.upArrow) setSelected((i) => (i - 1 + options.length) % options.length);
1981
- if (key.downArrow) setSelected((i) => (i + 1) % options.length);
1982
- if (key.return && options.length > 0) onAnswer(options[selected]);
2437
+ if (key.upArrow && choices.length > 0) setSelected((i) => (i - 1 + choices.length) % choices.length);
2438
+ if (key.downArrow && choices.length > 0) setSelected((i) => (i + 1) % choices.length);
2439
+ if (key.return && choices.length > 0) {
2440
+ const choice = choices[selected];
2441
+ if (isOtherChoice(choice)) onRequestCustom();
2442
+ else onAnswer(choice);
2443
+ }
1983
2444
  });
1984
2445
  return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1985
2446
  /* @__PURE__ */ jsxs7(Box7, { paddingX: 1, borderStyle: "single", borderColor: "gray", borderLeft: false, borderRight: false, children: [
1986
2447
  /* @__PURE__ */ jsx10(Text8, { color: "cyan", children: "? " }),
1987
2448
  /* @__PURE__ */ jsx10(Text8, { bold: true, children: message })
1988
2449
  ] }),
1989
- /* @__PURE__ */ jsx10(Box7, { flexDirection: "column", paddingX: 1, children: options.map((opt, i) => /* @__PURE__ */ jsxs7(Box7, { children: [
2450
+ /* @__PURE__ */ jsx10(Box7, { flexDirection: "column", paddingX: 1, children: choices.map((choice, i) => /* @__PURE__ */ jsxs7(Box7, { children: [
1990
2451
  /* @__PURE__ */ jsx10(Text8, { color: i === selected ? theme.baseToken.color.accent : void 0, children: i === selected ? "\u25B8 " : " " }),
1991
- /* @__PURE__ */ jsx10(Text8, { bold: i === selected, color: i === selected ? theme.baseToken.color.accent : void 0, children: opt })
2452
+ /* @__PURE__ */ jsx10(Text8, { bold: i === selected, color: i === selected ? theme.baseToken.color.accent : void 0, dimColor: isOtherChoice(choice) && i !== selected, children: choice })
1992
2453
  ] }, i)) })
1993
2454
  ] });
1994
2455
  }
1995
- function TextPrompt({ message, onAnswer }) {
2456
+ function TextPrompt({ message, onAnswer, inputLabel }) {
1996
2457
  const [input, setInput] = useState3("");
1997
2458
  const [cursor, setCursor] = useState3(0);
1998
2459
  useInput2((ch, key) => {
@@ -2028,10 +2489,12 @@ function TextPrompt({ message, onAnswer }) {
2028
2489
  /* @__PURE__ */ jsx10(Text8, { bold: true, children: message })
2029
2490
  ] }),
2030
2491
  /* @__PURE__ */ jsxs7(Box7, { paddingX: 1, borderStyle: "single", borderColor: "gray", borderLeft: false, borderRight: false, children: [
2031
- /* @__PURE__ */ jsxs7(Text8, { children: [
2492
+ inputLabel ? /* @__PURE__ */ jsxs7(Text8, { dimColor: true, children: [
2032
2493
  " ",
2033
- before
2034
- ] }),
2494
+ inputLabel,
2495
+ ": "
2496
+ ] }) : /* @__PURE__ */ jsx10(Text8, { children: " " }),
2497
+ /* @__PURE__ */ jsx10(Text8, { children: before }),
2035
2498
  /* @__PURE__ */ jsx10(Text8, { color: "white", inverse: true, children: after.length > 0 ? after[0] : " " }),
2036
2499
  /* @__PURE__ */ jsx10(Text8, { children: after.slice(1) })
2037
2500
  ] })
@@ -2170,11 +2633,6 @@ function OverlayPanel({ overlay }) {
2170
2633
  ] });
2171
2634
  }
2172
2635
 
2173
- // src/types/signal.ts
2174
- function getSignalField(sig, field) {
2175
- return sig.payload[field];
2176
- }
2177
-
2178
2636
  // src/tui/types/display-item.ts
2179
2637
  function entryToDisplayItem(entry) {
2180
2638
  const base = {
@@ -2402,7 +2860,7 @@ function ControllerPage({ onExit }) {
2402
2860
  }
2403
2861
  ) }),
2404
2862
  overlay && /* @__PURE__ */ jsx13(OverlayPanel, { overlay }),
2405
- pendingInquiry ? /* @__PURE__ */ jsx13(InquiryPrompt, { inquiry: pendingInquiry, onAnswer: (answer) => supervisor.resolveInquiry(answer) }) : /* @__PURE__ */ jsx13(InputArea, { onSubmit: handleSubmit, onExit, onEscape: dismissOverlay, busy }),
2863
+ pendingInquiry ? /* @__PURE__ */ jsx13(InquiryPrompt, { inquiry: pendingInquiry, onAnswer: (answer) => supervisor.resolveInquiry(answer) }) : /* @__PURE__ */ jsx13(InputArea, { onSubmit: handleSubmit, onExit, onEscape: dismissOverlay, busy, width: terminalCols }),
2406
2864
  /* @__PURE__ */ jsx13(
2407
2865
  StatusBar,
2408
2866
  {
@@ -2424,7 +2882,7 @@ function AppContent() {
2424
2882
  const { exit } = useApp();
2425
2883
  const supervisor = useSupervisor();
2426
2884
  const busy = useWayangState(supervisor.controllerAgent, "dynamicState.busy") ?? false;
2427
- const lastCtrlC = useRef2(0);
2885
+ const lastCtrlC = useRef3(0);
2428
2886
  const handleExit = useMemoizedFn(() => {
2429
2887
  exit();
2430
2888
  });
@@ -2448,7 +2906,7 @@ function AppContent() {
2448
2906
  // package.json
2449
2907
  var package_default = {
2450
2908
  name: "wayang-ai",
2451
- version: "0.6.2",
2909
+ version: "0.7.0",
2452
2910
  description: "Multi-agent orchestration platform",
2453
2911
  type: "module",
2454
2912
  license: "MIT",
@@ -2550,13 +3008,13 @@ function writeWelcome(workspace) {
2550
3008
 
2551
3009
  // src/tui/render.ts
2552
3010
  function renderInkUI(supervisor) {
2553
- return new Promise((resolve11) => {
3011
+ return new Promise((resolve12) => {
2554
3012
  process.stdout.write("\x1B[2J\x1B[H");
2555
3013
  writeWelcome(supervisor.ctx.workspaceDir);
2556
3014
  const { waitUntilExit } = render(
2557
3015
  React6.createElement(App, { supervisor })
2558
3016
  );
2559
- waitUntilExit().then(() => resolve11());
3017
+ waitUntilExit().then(() => resolve12());
2560
3018
  });
2561
3019
  }
2562
3020
 
@@ -2565,26 +3023,6 @@ import * as fs3 from "fs";
2565
3023
  import * as path3 from "path";
2566
3024
  import * as childProcess from "child_process";
2567
3025
  import prompts from "prompts";
2568
-
2569
- // src/types/config.ts
2570
- function validateConfig(config) {
2571
- for (const [name, provider] of Object.entries(config.providers)) {
2572
- if (!provider.endpoint) return `providers.${name}.endpoint is required`;
2573
- if (!provider.apiKey) return `providers.${name}.apiKey is required (set WAYANG_LLM_API_KEY or configure in config file)`;
2574
- if (!provider.modelName) return `providers.${name}.modelName is required`;
2575
- const window = provider.contextWindowTokens;
2576
- if (window !== void 0 && !(Number.isInteger(window) && window > 0)) {
2577
- return `providers.${name}.contextWindowTokens must be a positive integer (got ${JSON.stringify(window)})`;
2578
- }
2579
- }
2580
- const cp = config.controller.provider;
2581
- if (!config.providers[cp]) return `controller.provider "${cp}" not found`;
2582
- const wp = config.worker.provider;
2583
- if (!config.providers[wp]) return `worker.provider "${wp}" not found`;
2584
- return null;
2585
- }
2586
-
2587
- // src/onboard.ts
2588
3026
  function fillApiKeys(config) {
2589
3027
  const envKey = process.env.WAYANG_LLM_API_KEY || "";
2590
3028
  for (const provider of Object.values(config.providers)) {
@@ -3508,10 +3946,10 @@ var SignalQueue = class {
3508
3946
  waitForSignal() {
3509
3947
  const unread = this.state.get("signals").filter((s) => s.status === "unread");
3510
3948
  if (unread.length > 0) return Promise.resolve();
3511
- return new Promise((resolve11) => {
3949
+ return new Promise((resolve12) => {
3512
3950
  this.resolveWait = () => {
3513
3951
  this.logger.debug("waitForSignal resolved");
3514
- resolve11();
3952
+ resolve12();
3515
3953
  };
3516
3954
  });
3517
3955
  }
@@ -3571,267 +4009,12 @@ var SignalQueue = class {
3571
4009
  }
3572
4010
  };
3573
4011
 
3574
- // src/services/task/task-pool-state.ts
3575
- import { join as join6 } from "path";
3576
- var TaskPoolState = class extends BaseWayangState {
3577
- jsonFile;
3578
- logger;
3579
- constructor(ctx) {
3580
- const jsonFile = new JSONFileHelper(join6(ctx.sessionDir, "tasks.json"));
3581
- super(
3582
- {
3583
- tasks: {
3584
- pending: [],
3585
- running: [],
3586
- history: []
3587
- }
3588
- },
3589
- [{ path: "tasks", helper: jsonFile }]
3590
- );
3591
- this.jsonFile = jsonFile;
3592
- this.logger = ctx.logger;
3593
- }
3594
- async restore() {
3595
- const data = this.jsonFile.read();
3596
- if (data) {
3597
- this.data.tasks = data;
3598
- }
3599
- this.logger.info("TaskPoolState restored");
3600
- }
3601
- };
3602
-
3603
- // src/services/agents/base-agent.ts
3604
- import { streamText, stepCountIs, hasToolCall } from "ai";
3605
-
3606
- // src/services/agents/model-factory.ts
3607
- import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
3608
- function createModel(provider) {
3609
- const compatible = createOpenAICompatible({
3610
- name: "wayang",
3611
- baseURL: provider.endpoint,
3612
- apiKey: provider.apiKey
3613
- });
3614
- return compatible.chatModel(provider.modelName);
3615
- }
3616
-
3617
- // src/utils/id.ts
3618
- function generateId(prefix) {
3619
- return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
3620
- }
3621
- function formatTimestamp(date = /* @__PURE__ */ new Date()) {
3622
- const Y = date.getFullYear();
3623
- const M = String(date.getMonth() + 1).padStart(2, "0");
3624
- const D = String(date.getDate()).padStart(2, "0");
3625
- const h = String(date.getHours()).padStart(2, "0");
3626
- const m = String(date.getMinutes()).padStart(2, "0");
3627
- const s = String(date.getSeconds()).padStart(2, "0");
3628
- return `${Y}${M}${D}-${h}${m}${s}`;
3629
- }
4012
+ // src/services/worker-transcript.ts
4013
+ import { closeSync as closeSync2, existsSync as existsSync5, openSync as openSync2, readSync, statSync } from "fs";
4014
+ import { join as join7 } from "path";
3630
4015
 
3631
- // src/utils/time.ts
3632
- function nowISO() {
3633
- const now = /* @__PURE__ */ new Date();
3634
- const off = -now.getTimezoneOffset();
3635
- const sign = off >= 0 ? "+" : "-";
3636
- const absOff = Math.abs(off);
3637
- const hh = String(Math.floor(absOff / 60)).padStart(2, "0");
3638
- const mm = String(absOff % 60).padStart(2, "0");
3639
- const pad = (n, len = 2) => String(n).padStart(len, "0");
3640
- return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}${sign}${hh}:${mm}`;
3641
- }
3642
-
3643
- // src/services/agents/base-agent.ts
3644
- var DEFAULT_LLM_IDLE_TIMEOUT_MS = 3e5;
3645
- var BaseAgent = class {
3646
- id;
3647
- abortController;
3648
- model;
3649
- hooks = {};
3650
- constructor(provider) {
3651
- this.id = generateId("agent");
3652
- this.abortController = new AbortController();
3653
- this.model = createModel(provider);
3654
- }
3655
- /** Set lifecycle hooks. */
3656
- setHooks(hooks) {
3657
- this.hooks = hooks;
3658
- }
3659
- /** Core streaming primitive — yields text chunks, returns final result.
3660
- * Catches abort signal internally so callers see a clean completion. */
3661
- async *streamLoop(opts) {
3662
- const step = 0;
3663
- const idleTimeoutMs = opts.llmIdleTimeoutMs ?? DEFAULT_LLM_IDLE_TIMEOUT_MS;
3664
- const callAbort = new AbortController();
3665
- const forwardAbort = () => callAbort.abort();
3666
- if (this.abortController.signal.aborted) {
3667
- callAbort.abort();
3668
- } else {
3669
- this.abortController.signal.addEventListener("abort", forwardAbort);
3670
- }
3671
- let watchdog = null;
3672
- let timedOut = false;
3673
- let toolsInFlight = 0;
3674
- let finished = false;
3675
- const clearWatchdog = () => {
3676
- if (watchdog !== null) {
3677
- clearTimeout(watchdog);
3678
- watchdog = null;
3679
- }
3680
- };
3681
- const armWatchdog = () => {
3682
- if (idleTimeoutMs <= 0 || finished || toolsInFlight > 0) return;
3683
- clearWatchdog();
3684
- watchdog = setTimeout(() => {
3685
- timedOut = true;
3686
- callAbort.abort();
3687
- }, idleTimeoutMs);
3688
- };
3689
- const tools = pauseWatchdogDuringTools(
3690
- opts.tools,
3691
- () => {
3692
- toolsInFlight++;
3693
- clearWatchdog();
3694
- },
3695
- () => {
3696
- toolsInFlight--;
3697
- if (toolsInFlight === 0) armWatchdog();
3698
- }
3699
- );
3700
- let stream;
3701
- const stopConditions = [];
3702
- if (opts.maxSteps) stopConditions.push(stepCountIs(opts.maxSteps));
3703
- if (opts.stopTools?.length) {
3704
- for (const name of opts.stopTools) {
3705
- stopConditions.push(hasToolCall(name));
3706
- }
3707
- }
3708
- let startTime;
3709
- let firstChunkAt;
3710
- let stepStartedAt;
3711
- const onStep = opts.onStep ? (event) => {
3712
- armWatchdog();
3713
- const meta = { startedAt: stepStartedAt, firstTokenAt: firstChunkAt };
3714
- firstChunkAt = void 0;
3715
- stepStartedAt = nowISO();
3716
- opts.onStep(event, meta);
3717
- } : void 0;
3718
- try {
3719
- this.hooks.beforeLLM?.({ messageCount: opts.messages.length, messages: opts.messages });
3720
- startTime = Date.now();
3721
- stepStartedAt = nowISO();
3722
- armWatchdog();
3723
- stream = streamText({
3724
- model: this.model,
3725
- system: opts.system,
3726
- messages: opts.messages,
3727
- tools,
3728
- toolChoice: opts.toolChoice,
3729
- stopWhen: stopConditions.length > 0 ? stopConditions : void 0,
3730
- abortSignal: callAbort.signal,
3731
- ...opts.prepareStep && { prepareStep: opts.prepareStep },
3732
- onStepFinish: onStep,
3733
- onError: ({ error }) => {
3734
- opts.onStreamError?.(error);
3735
- },
3736
- // SignalEntry entries are trusted system-generated notifications emitted
3737
- // as `system`-role messages (in-band, order-sensitive). AI SDK v6 warns
3738
- // on any system message inside `messages` unless this is opted in.
3739
- allowSystemInMessages: true
3740
- });
3741
- } catch (err) {
3742
- finished = true;
3743
- clearWatchdog();
3744
- this.abortController.signal.removeEventListener("abort", forwardAbort);
3745
- if (this.abortController.signal.aborted) {
3746
- return { text: "", toolResults: [], usage: void 0 };
3747
- }
3748
- throw err;
3749
- }
3750
- try {
3751
- for await (const chunk of stream.textStream) {
3752
- armWatchdog();
3753
- if (chunk && firstChunkAt === void 0) firstChunkAt = nowISO();
3754
- yield chunk;
3755
- }
3756
- } catch (err) {
3757
- if (this.abortController.signal.aborted) {
3758
- return { text: "", toolResults: [], usage: void 0 };
3759
- }
3760
- if (timedOut) {
3761
- throw new Error(
3762
- `LLM call idle timeout: no stream progress (text chunk or step finish) for ${idleTimeoutMs}ms`
3763
- );
3764
- }
3765
- if (this.abortController.signal.aborted) {
3766
- return { text: "", toolResults: [], usage: void 0 };
3767
- }
3768
- throw err;
3769
- } finally {
3770
- finished = true;
3771
- clearWatchdog();
3772
- this.abortController.signal.removeEventListener("abort", forwardAbort);
3773
- }
3774
- const duration = Date.now() - startTime;
3775
- const text = await stream.text;
3776
- const reasoningText = await stream.reasoningText;
3777
- const toolResults = await stream.toolResults;
3778
- const rawUsage = await stream.usage;
3779
- const finishReason = await stream.finishReason;
3780
- const steps = await stream.steps;
3781
- const usage = rawUsage ? { inputTokens: rawUsage.inputTokens ?? 0, outputTokens: rawUsage.outputTokens ?? 0 } : void 0;
3782
- this.hooks.afterLLM?.({ step, usage, durationMs: duration });
3783
- return {
3784
- text,
3785
- reasoningText,
3786
- toolResults: toolResults?.map((tr) => ({
3787
- toolCallId: tr.toolCallId,
3788
- result: tr.result,
3789
- isError: tr.isError
3790
- })),
3791
- usage,
3792
- finishReason,
3793
- steps: steps?.length
3794
- };
3795
- }
3796
- /** Batch wrapper — iterates streamLoop and returns the final result. */
3797
- async collectLoop(opts) {
3798
- const gen = this.streamLoop(opts);
3799
- let result;
3800
- while (true) {
3801
- const { done, value } = await gen.next();
3802
- if (done) {
3803
- result = value;
3804
- break;
3805
- }
3806
- }
3807
- return result;
3808
- }
3809
- abort() {
3810
- this.abortController.abort();
3811
- }
3812
- };
3813
- function pauseWatchdogDuringTools(tools, onStart, onEnd) {
3814
- const wrapped = {};
3815
- for (const [name, def] of Object.entries(tools ?? {})) {
3816
- const original = def?.execute;
3817
- if (typeof original !== "function") {
3818
- wrapped[name] = def;
3819
- continue;
3820
- }
3821
- wrapped[name] = {
3822
- ...def,
3823
- execute: async (...args) => {
3824
- onStart();
3825
- try {
3826
- return await original(...args);
3827
- } finally {
3828
- onEnd();
3829
- }
3830
- }
3831
- };
3832
- }
3833
- return wrapped;
3834
- }
4016
+ // src/services/agents/worker-state.ts
4017
+ import { join as join6 } from "path";
3835
4018
 
3836
4019
  // src/services/agents/utils/conversation-to-sdk-messages.ts
3837
4020
  function conversationToSdkMessages(entries) {
@@ -4005,6 +4188,32 @@ function toolOutputText(output) {
4005
4188
  return toolPayloadText(output);
4006
4189
  }
4007
4190
 
4191
+ // src/utils/id.ts
4192
+ function generateId(prefix) {
4193
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
4194
+ }
4195
+ function formatTimestamp(date = /* @__PURE__ */ new Date()) {
4196
+ const Y = date.getFullYear();
4197
+ const M = String(date.getMonth() + 1).padStart(2, "0");
4198
+ const D = String(date.getDate()).padStart(2, "0");
4199
+ const h = String(date.getHours()).padStart(2, "0");
4200
+ const m = String(date.getMinutes()).padStart(2, "0");
4201
+ const s = String(date.getSeconds()).padStart(2, "0");
4202
+ return `${Y}${M}${D}-${h}${m}${s}`;
4203
+ }
4204
+
4205
+ // src/utils/time.ts
4206
+ function nowISO() {
4207
+ const now = /* @__PURE__ */ new Date();
4208
+ const off = -now.getTimezoneOffset();
4209
+ const sign = off >= 0 ? "+" : "-";
4210
+ const absOff = Math.abs(off);
4211
+ const hh = String(Math.floor(absOff / 60)).padStart(2, "0");
4212
+ const mm = String(absOff % 60).padStart(2, "0");
4213
+ const pad = (n, len = 2) => String(n).padStart(len, "0");
4214
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}${sign}${hh}:${mm}`;
4215
+ }
4216
+
4008
4217
  // src/services/agents/context-manager.ts
4009
4218
  var DEFAULT_CONTEXT_WINDOW_TOKENS = 256e3;
4010
4219
  var COMPACT_THRESHOLD_RATIO = 0.9;
@@ -4180,121 +4389,641 @@ ${this.compactSummary}` : "";
4180
4389
  }
4181
4390
  }
4182
4391
  }
4183
- breakdown.total = breakdown.systemPrompt + breakdown.user + breakdown.assistant + breakdown.toolCalls + breakdown.toolResults + breakdown.compactSummary;
4184
- return breakdown;
4185
- }
4186
- /**
4187
- * Restore compact state from a summary computed by the state layer.
4188
- *
4189
- * Call this after the state layer's restore() has already sliced the
4190
- * in-memory conversation to the restore window (entries before the cut are
4191
- * gone from memory). The window itself is the verbatim zone, so
4192
- * compactStartIdx resets to 0 — see the field's semantics note.
4193
- */
4194
- restoreCompact(summary) {
4195
- this.compactSummary = summary;
4196
- this.compactStartIdx = 0;
4197
- }
4198
- /** Get the cached compaction summary, or null if no compaction is active. */
4199
- getCompactSummary() {
4200
- return this.compactSummary;
4392
+ breakdown.total = breakdown.systemPrompt + breakdown.user + breakdown.assistant + breakdown.toolCalls + breakdown.toolResults + breakdown.compactSummary;
4393
+ return breakdown;
4394
+ }
4395
+ /**
4396
+ * Restore compact state from a summary computed by the state layer.
4397
+ *
4398
+ * Call this after the state layer's restore() has already sliced the
4399
+ * in-memory conversation to the restore window (entries before the cut are
4400
+ * gone from memory). The window itself is the verbatim zone, so
4401
+ * compactStartIdx resets to 0 — see the field's semantics note.
4402
+ */
4403
+ restoreCompact(summary) {
4404
+ this.compactSummary = summary;
4405
+ this.compactStartIdx = 0;
4406
+ }
4407
+ /** Get the cached compaction summary, or null if no compaction is active. */
4408
+ getCompactSummary() {
4409
+ return this.compactSummary;
4410
+ }
4411
+ /**
4412
+ * Compact the conversation by summarizing all current entries.
4413
+ *
4414
+ * Appends a compact marker entry to the conversation (and JSONL file).
4415
+ * Does NOT modify or remove any existing entries — storage is append-only.
4416
+ *
4417
+ * The summarizer receives ALL entries and returns a summary.
4418
+ * After compaction, getSystemPrompt() carries the summary and getMessages() returns only the entries appended after the marker.
4419
+ *
4420
+ * @param meta Optional provenance recorded on the marker (model name).
4421
+ */
4422
+ async compact(summarizer, meta) {
4423
+ const conversation = this.state.get("conversation");
4424
+ if (conversation.length < 4) return;
4425
+ const fromEntryId = conversation[0]?.uuid;
4426
+ const toEntryId = conversation[conversation.length - 1]?.uuid;
4427
+ const summary = await summarizer(conversation);
4428
+ this.compactSummary = summary;
4429
+ this.compactStartIdx = conversation.length;
4430
+ this.lastReportedTokens = null;
4431
+ const markerTs = nowISO();
4432
+ this.state.append("conversation", {
4433
+ type: "system" /* System */,
4434
+ uuid: generateId("compact"),
4435
+ parentUuid: null,
4436
+ sessionId: this.state.get("runtimeState.session.id") ?? "",
4437
+ timestamp: markerTs,
4438
+ subtype: "compact" /* Compact */,
4439
+ content: summary,
4440
+ // System entries are point-in-time events: startedAt/endedAt share the
4441
+ // marker's creation moment.
4442
+ startedAt: markerTs,
4443
+ endedAt: markerTs,
4444
+ compactMeta: {
4445
+ summarizedTokenCount: estimateConversationTokens(conversation),
4446
+ ...fromEntryId !== void 0 && { fromEntryId },
4447
+ ...toEntryId !== void 0 && { toEntryId },
4448
+ ...meta?.model !== void 0 && { model: meta.model },
4449
+ createdAt: markerTs
4450
+ }
4451
+ });
4452
+ }
4453
+ };
4454
+
4455
+ // src/services/agents/worker-state.ts
4456
+ function workerDir(sessionDir, workerId) {
4457
+ return join6(sessionDir, "workers", workerId);
4458
+ }
4459
+ var WorkerState = class extends BaseWayangState {
4460
+ constructor(sessionDir, workerId, logger) {
4461
+ const dir = workerDir(sessionDir, workerId);
4462
+ const jsonFile = new JSONFileHelper(join6(dir, "runtime-state.json"));
4463
+ const jsonlFile = new JSONLFileHelper(join6(dir, "conversation.jsonl"));
4464
+ const initialData = {
4465
+ runtimeState: {
4466
+ session: { id: workerId, startedAt: Date.now() },
4467
+ task: { id: "", description: "" }
4468
+ },
4469
+ conversation: [],
4470
+ inbox: []
4471
+ };
4472
+ super(initialData, [
4473
+ { path: "runtimeState", helper: jsonFile },
4474
+ { path: "conversation", helper: jsonlFile }
4475
+ ]);
4476
+ this.logger = logger;
4477
+ this.jsonFile = jsonFile;
4478
+ this.jsonlFile = jsonlFile;
4479
+ }
4480
+ logger;
4481
+ jsonFile;
4482
+ jsonlFile;
4483
+ async restore() {
4484
+ const runtimeState = this.jsonFile.read();
4485
+ if (runtimeState) {
4486
+ this.data.runtimeState = runtimeState;
4487
+ }
4488
+ const allEntries = this.jsonlFile.read();
4489
+ const compactIdx = allEntries.findLastIndex(isCompactMarker);
4490
+ if (compactIdx === -1) {
4491
+ if (allEntries.length > 0) {
4492
+ this.data.conversation = allEntries;
4493
+ }
4494
+ } else {
4495
+ let budget = RECENT_TOKEN_BUDGET;
4496
+ let cutIdx = 0;
4497
+ for (let i = compactIdx - 1; i >= 0; i--) {
4498
+ budget -= estimateEntryTokens(allEntries[i]);
4499
+ if (budget <= 0) {
4500
+ cutIdx = i + 1;
4501
+ break;
4502
+ }
4503
+ }
4504
+ this.data.conversation = allEntries.slice(cutIdx);
4505
+ }
4506
+ this.logger.info("WorkerState restored");
4507
+ }
4508
+ };
4509
+
4510
+ // src/services/worker-transcript.ts
4511
+ var WORKER_TRANSCRIPT_FILE = "conversation.jsonl";
4512
+ var MAX_TRANSCRIPT_RESULT_CHARS = 4e4;
4513
+ var WORKER_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
4514
+ function isValidWorkerId(workerId) {
4515
+ return WORKER_ID_PATTERN.test(workerId) && workerId !== "." && workerId !== "..";
4516
+ }
4517
+ function workerTranscriptPath(sessionDir, workerId) {
4518
+ return join7(workerDir(sessionDir, workerId), WORKER_TRANSCRIPT_FILE);
4519
+ }
4520
+ function readWorkerTranscript(sessionDir, workerId) {
4521
+ if (!isValidWorkerId(workerId)) return null;
4522
+ const path7 = workerTranscriptPath(sessionDir, workerId);
4523
+ if (!existsSync5(path7)) return null;
4524
+ return new JSONLFileHelper(path7).read();
4525
+ }
4526
+ var MAX_TRANSCRIPT_WINDOW_BYTES = 2 * 1024 * 1024;
4527
+ var MIN_WINDOW_BYTES = 64 * 1024;
4528
+ var ENTRY_BYTES_ESTIMATE = 1024;
4529
+ function parseWindow(text, startsAtFileHead) {
4530
+ let body = text;
4531
+ if (!startsAtFileHead) {
4532
+ const nl = body.indexOf("\n");
4533
+ if (nl === -1) return { entries: [], incompleteTail: false };
4534
+ body = body.slice(nl + 1);
4535
+ }
4536
+ const endsComplete = body.endsWith("\n");
4537
+ const lines = body.split("\n");
4538
+ if (endsComplete) lines.pop();
4539
+ const entries = [];
4540
+ let incompleteTail = false;
4541
+ for (let i = 0; i < lines.length; i++) {
4542
+ const line = lines[i];
4543
+ if (line.length === 0) continue;
4544
+ try {
4545
+ entries.push(JSON.parse(line));
4546
+ } catch {
4547
+ if (i === lines.length - 1 && !endsComplete) incompleteTail = true;
4548
+ }
4549
+ }
4550
+ return { entries, incompleteTail };
4551
+ }
4552
+ function readWorkerTranscriptWindow(sessionDir, workerId, opts = {}) {
4553
+ if (!isValidWorkerId(workerId)) return null;
4554
+ const path7 = workerTranscriptPath(sessionDir, workerId);
4555
+ let size;
4556
+ try {
4557
+ size = statSync(path7).size;
4558
+ } catch {
4559
+ return null;
4560
+ }
4561
+ if (size === 0) {
4562
+ return { entries: [], incompleteTail: false, windowTruncated: false, coversFile: true };
4563
+ }
4564
+ const matches = opts.matches ?? (() => true);
4565
+ const need = opts.need ?? Number.POSITIVE_INFINITY;
4566
+ let windowBytes = need === Number.POSITIVE_INFINITY ? MAX_TRANSCRIPT_WINDOW_BYTES : Math.min(MAX_TRANSCRIPT_WINDOW_BYTES, Math.max(MIN_WINDOW_BYTES, Math.ceil(need) * ENTRY_BYTES_ESTIMATE));
4567
+ for (; ; ) {
4568
+ const readLen = Math.min(size, windowBytes);
4569
+ const start = size - readLen;
4570
+ const chunk = Buffer.alloc(readLen);
4571
+ const fd = openSync2(path7, "r");
4572
+ try {
4573
+ readSync(fd, chunk, 0, readLen, start);
4574
+ } finally {
4575
+ closeSync2(fd);
4576
+ }
4577
+ const parsed = parseWindow(chunk.toString("utf8"), start === 0);
4578
+ const enough = parsed.entries.filter(matches).length >= need;
4579
+ const canGrow = start > 0 && windowBytes < MAX_TRANSCRIPT_WINDOW_BYTES;
4580
+ if (enough || !canGrow) {
4581
+ return {
4582
+ entries: parsed.entries,
4583
+ incompleteTail: parsed.incompleteTail,
4584
+ windowTruncated: start > 0,
4585
+ coversFile: start === 0
4586
+ };
4587
+ }
4588
+ windowBytes = Math.min(MAX_TRANSCRIPT_WINDOW_BYTES, windowBytes * 2);
4589
+ }
4590
+ }
4591
+ function blockText(block) {
4592
+ if (typeof block === "string") return block;
4593
+ if (block && typeof block === "object") {
4594
+ const b = block;
4595
+ if (b.type === "text" && typeof b.text === "string") return b.text;
4596
+ return JSON.stringify(block);
4597
+ }
4598
+ return String(block ?? "");
4599
+ }
4600
+ function entryText(entry) {
4601
+ const raw = entry;
4602
+ const content = raw.message?.content ?? raw.content ?? "";
4603
+ if (typeof content === "string") return content;
4604
+ if (Array.isArray(content)) return content.map(blockText).join("\n");
4605
+ return content === null || content === void 0 ? "" : String(content);
4606
+ }
4607
+ function entryToolCalls(entry) {
4608
+ const calls = entry.toolCalls;
4609
+ if (!Array.isArray(calls)) return [];
4610
+ return calls.filter((c) => typeof c?.toolName === "string").map((c) => ({ toolName: c.toolName, arguments: c.arguments }));
4611
+ }
4612
+ function entryToolResults(entry) {
4613
+ const results = entry.toolResults;
4614
+ if (!Array.isArray(results)) return [];
4615
+ return results.map((r) => ({
4616
+ output: toolOutputText(r.output),
4617
+ isError: r.isError
4618
+ }));
4619
+ }
4620
+ function entryLabel(entry) {
4621
+ const subtype = entry.subtype;
4622
+ return subtype ? `${entry.type}/${subtype}` : entry.type;
4623
+ }
4624
+ var TERMINAL_SUBTYPES = /* @__PURE__ */ new Set(["task_result" /* TaskResult */, "error" /* Error */]);
4625
+ function entryTimestamp(entry) {
4626
+ const e = entry;
4627
+ for (const raw of [e.timestamp, e.endedAt, e.startedAt]) {
4628
+ if (typeof raw === "string") {
4629
+ const ms = Date.parse(raw);
4630
+ if (Number.isFinite(ms)) return ms;
4631
+ }
4632
+ }
4633
+ return null;
4634
+ }
4635
+ function summarizeTranscript(entries) {
4636
+ let lastActivityAt = null;
4637
+ for (let i = entries.length - 1; i >= 0; i--) {
4638
+ lastActivityAt = entryTimestamp(entries[i]);
4639
+ if (lastActivityAt !== null) break;
4640
+ }
4641
+ let currentTool = null;
4642
+ let terminalSubtype = null;
4643
+ for (let i = entries.length - 1; i >= 0 && (currentTool === null || terminalSubtype === null); i--) {
4644
+ const entry = entries[i];
4645
+ if (currentTool === null) {
4646
+ const calls = entryToolCalls(entry);
4647
+ if (calls.length > 0) currentTool = calls[calls.length - 1].toolName;
4648
+ }
4649
+ if (terminalSubtype === null && entry.type === "system" /* System */ && TERMINAL_SUBTYPES.has(String(entry.subtype))) {
4650
+ terminalSubtype = String(entry.subtype);
4651
+ }
4652
+ }
4653
+ return {
4654
+ totalEntries: entries.length,
4655
+ lastActivityAt,
4656
+ lastEntryType: entries.length > 0 ? entries[entries.length - 1].type : null,
4657
+ currentTool,
4658
+ hasTerminalEntry: terminalSubtype !== null,
4659
+ terminalSubtype
4660
+ };
4661
+ }
4662
+ function formatDuration(ms) {
4663
+ if (!Number.isFinite(ms)) return "unknown";
4664
+ const abs = Math.max(0, ms);
4665
+ if (abs < 1e3) return `${Math.round(abs)}ms`;
4666
+ if (abs < 6e4) return `${(abs / 1e3).toFixed(1)}s`;
4667
+ if (abs < 36e5) {
4668
+ const m2 = Math.floor(abs / 6e4);
4669
+ const s = Math.floor(abs % 6e4 / 1e3);
4670
+ return `${m2}m ${String(s).padStart(2, "0")}s`;
4671
+ }
4672
+ const h = Math.floor(abs / 36e5);
4673
+ const m = Math.floor(abs % 36e5 / 6e4);
4674
+ return `${h}h ${String(m).padStart(2, "0")}m`;
4675
+ }
4676
+ function formatLastActivity(lastActivityAt, now) {
4677
+ if (lastActivityAt === null) return "unknown";
4678
+ return `${new Date(lastActivityAt).toISOString()} (${formatDuration(now - lastActivityAt)} ago)`;
4679
+ }
4680
+ function selectWindow(entries, offset, limit) {
4681
+ const total = entries.length;
4682
+ const safeOffset = Math.max(0, Number.isFinite(offset) ? Math.trunc(offset) : 0);
4683
+ const safeLimit = Math.max(0, Number.isFinite(limit) ? Math.trunc(limit) : 0);
4684
+ const end = Math.max(0, total - safeOffset);
4685
+ const start = safeLimit === 0 ? end : Math.max(0, end - safeLimit);
4686
+ const window = entries.slice(start, end);
4687
+ return {
4688
+ entries: window,
4689
+ totalEntries: total,
4690
+ returnedEntries: window.length,
4691
+ offset: safeOffset,
4692
+ limit: safeLimit,
4693
+ startIndex: window.length > 0 ? start + 1 : 0,
4694
+ endIndex: window.length > 0 ? end : 0,
4695
+ windowTruncated: window.length < total
4696
+ };
4697
+ }
4698
+ function formatTranscriptEntry(entry, position, opts) {
4699
+ const body = entryText(entry).replace(/\r/g, "");
4700
+ const toolNames = entryToolCalls(entry).map((c) => c.toolName);
4701
+ const toolNote = toolNames.length > 0 ? ` (tools: ${toolNames.join(", ")})` : "";
4702
+ const head = ` #${position} [${entryLabel(entry)}]${toolNote}`;
4703
+ const cap = opts.maxChars;
4704
+ const cut2 = cap !== null && body.length > cap;
4705
+ const shown = cut2 ? body.slice(0, cap) + "\u2026" : body;
4706
+ const marker = cut2 ? ` (truncated: ${cap}/${body.length} chars)` : "";
4707
+ const lines = [];
4708
+ if (shown.length === 0) {
4709
+ lines.push(`${head}${marker} (empty)`);
4710
+ } else if (!shown.includes("\n")) {
4711
+ lines.push(`${head}${marker} ${shown}`);
4712
+ } else {
4713
+ lines.push(`${head}${marker}`);
4714
+ for (const line of shown.split("\n")) lines.push(` ${line}`);
4715
+ }
4716
+ if (opts.includeToolDetail) {
4717
+ for (const call of entryToolCalls(entry)) {
4718
+ const args = call.arguments ?? "";
4719
+ const argsShown = cap !== null && args.length > cap ? args.slice(0, cap) + "\u2026" : args;
4720
+ lines.push(` \u21B3 ${call.toolName} ${argsShown}`.trimEnd());
4721
+ }
4722
+ for (const result of entryToolResults(entry)) {
4723
+ const output = result.output ?? "";
4724
+ const outputShown = cap !== null && output.length > cap ? output.slice(0, cap) + "\u2026" : output;
4725
+ lines.push(` \u21B3 result${result.isError ? " [error]" : ""}: ${outputShown}`.trimEnd());
4726
+ }
4727
+ }
4728
+ return { lines, truncated: cut2, originalChars: body.length };
4729
+ }
4730
+ function shrinkWindow(win, dropFromStart) {
4731
+ const drop = Math.max(0, Math.min(dropFromStart, win.entries.length));
4732
+ if (drop === 0) return win;
4733
+ const entries = win.entries.slice(drop);
4734
+ return {
4735
+ ...win,
4736
+ entries,
4737
+ returnedEntries: entries.length,
4738
+ startIndex: win.startIndex + drop,
4739
+ windowTruncated: true
4740
+ };
4741
+ }
4742
+ function renderEntriesFitting(entries, startIndex, opts, budgetChars) {
4743
+ const formatted = entries.map((entry, i) => formatTranscriptEntry(entry, startIndex + i, opts));
4744
+ let used = 0;
4745
+ let first = formatted.length;
4746
+ for (let i = formatted.length - 1; i >= 0; i--) {
4747
+ const size = formatted[i].lines.reduce((n, line) => n + line.length + 1, 0);
4748
+ if (used + size > budgetChars && first < formatted.length) break;
4749
+ used += size;
4750
+ first = i;
4751
+ }
4752
+ const kept = formatted.slice(first);
4753
+ return {
4754
+ lines: kept.flatMap((f) => f.lines),
4755
+ dropped: first,
4756
+ shortened: kept.filter((f) => f.truncated).length
4757
+ };
4758
+ }
4759
+ function buildWindowMeta(win, opts) {
4760
+ return {
4761
+ totalEntries: win.totalEntries,
4762
+ returnedEntries: win.returnedEntries,
4763
+ offset: win.offset,
4764
+ limit: win.limit,
4765
+ maxChars: opts.maxChars,
4766
+ full: opts.full,
4767
+ truncated: win.windowTruncated || opts.shortenedEntries > 0,
4768
+ windowTruncated: win.windowTruncated,
4769
+ startIndex: win.startIndex,
4770
+ endIndex: win.endIndex,
4771
+ shortenedEntries: opts.shortenedEntries
4772
+ };
4773
+ }
4774
+ function buildTruncationNotice(meta) {
4775
+ const perEntry = meta.maxChars === null ? "none" : `${meta.maxChars}`;
4776
+ const shortened = meta.shortenedEntries > 0 ? ` (${meta.shortenedEntries} entries shortened)` : "";
4777
+ if (meta.windowTruncated) {
4778
+ const how = meta.full ? "output cap reached \u2014 page further back with offset" : "pass offset/limit or full=true";
4779
+ return `[truncated: showing ${meta.returnedEntries} of ${meta.totalEntries} entries (${meta.startIndex}-${meta.endIndex}), per-entry limit ${perEntry} chars${shortened}; ${how}]`;
4780
+ }
4781
+ if (meta.shortenedEntries > 0) {
4782
+ return `[truncated: showing all ${meta.totalEntries} entries, per-entry limit ${perEntry} chars${shortened}; pass full=true for complete content]`;
4783
+ }
4784
+ return null;
4785
+ }
4786
+ function buildTranscriptHeader(meta) {
4787
+ if (meta.totalEntries === 0) return "Conversation (0 entries):";
4788
+ if (!meta.windowTruncated) return `Conversation (${meta.totalEntries} entries):`;
4789
+ return `Conversation (${meta.totalEntries} entries total, showing ${meta.startIndex}-${meta.endIndex} by offset=${meta.offset} limit=${meta.limit}):`;
4790
+ }
4791
+
4792
+ // src/services/task/task-pool-state.ts
4793
+ import { join as join8 } from "path";
4794
+ var TaskPoolState = class extends BaseWayangState {
4795
+ jsonFile;
4796
+ logger;
4797
+ constructor(ctx) {
4798
+ const jsonFile = new JSONFileHelper(join8(ctx.sessionDir, "tasks.json"));
4799
+ super(
4800
+ {
4801
+ tasks: {
4802
+ pending: [],
4803
+ running: [],
4804
+ history: []
4805
+ }
4806
+ },
4807
+ [{ path: "tasks", helper: jsonFile }]
4808
+ );
4809
+ this.jsonFile = jsonFile;
4810
+ this.logger = ctx.logger;
4811
+ }
4812
+ async restore() {
4813
+ const data = this.jsonFile.read();
4814
+ if (data) {
4815
+ this.data.tasks = data;
4816
+ }
4817
+ this.logger.info("TaskPoolState restored");
4818
+ }
4819
+ };
4820
+
4821
+ // src/services/agents/base-agent.ts
4822
+ import { streamText, stepCountIs, hasToolCall } from "ai";
4823
+
4824
+ // src/services/agents/model-factory.ts
4825
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4826
+ function createModel(provider) {
4827
+ const compatible = createOpenAICompatible({
4828
+ name: "wayang",
4829
+ baseURL: provider.endpoint,
4830
+ apiKey: provider.apiKey
4831
+ });
4832
+ return compatible.chatModel(provider.modelName);
4833
+ }
4834
+
4835
+ // src/services/agents/base-agent.ts
4836
+ var DEFAULT_LLM_IDLE_TIMEOUT_MS = 3e5;
4837
+ var BaseAgent = class {
4838
+ id;
4839
+ abortController;
4840
+ model;
4841
+ hooks = {};
4842
+ constructor(provider) {
4843
+ this.id = generateId("agent");
4844
+ this.abortController = new AbortController();
4845
+ this.model = createModel(provider);
4846
+ }
4847
+ /** Set lifecycle hooks. */
4848
+ setHooks(hooks) {
4849
+ this.hooks = hooks;
4850
+ }
4851
+ /** Core streaming primitive — yields text chunks, returns final result.
4852
+ * Catches abort signal internally so callers see a clean completion. */
4853
+ async *streamLoop(opts) {
4854
+ const step = 0;
4855
+ const idleTimeoutMs = opts.llmIdleTimeoutMs ?? DEFAULT_LLM_IDLE_TIMEOUT_MS;
4856
+ const callAbort = new AbortController();
4857
+ const forwardAbort = () => callAbort.abort();
4858
+ if (this.abortController.signal.aborted) {
4859
+ callAbort.abort();
4860
+ } else {
4861
+ this.abortController.signal.addEventListener("abort", forwardAbort);
4862
+ }
4863
+ let watchdog = null;
4864
+ let timedOut = false;
4865
+ let toolsInFlight = 0;
4866
+ let finished = false;
4867
+ const clearWatchdog = () => {
4868
+ if (watchdog !== null) {
4869
+ clearTimeout(watchdog);
4870
+ watchdog = null;
4871
+ }
4872
+ };
4873
+ const armWatchdog = () => {
4874
+ if (idleTimeoutMs <= 0 || finished || toolsInFlight > 0) return;
4875
+ clearWatchdog();
4876
+ watchdog = setTimeout(() => {
4877
+ timedOut = true;
4878
+ callAbort.abort();
4879
+ }, idleTimeoutMs);
4880
+ };
4881
+ const tools = pauseWatchdogDuringTools(
4882
+ opts.tools,
4883
+ () => {
4884
+ toolsInFlight++;
4885
+ clearWatchdog();
4886
+ },
4887
+ () => {
4888
+ toolsInFlight--;
4889
+ if (toolsInFlight === 0) armWatchdog();
4890
+ }
4891
+ );
4892
+ let stream;
4893
+ const stopConditions = [];
4894
+ if (opts.maxSteps) stopConditions.push(stepCountIs(opts.maxSteps));
4895
+ if (opts.stopTools?.length) {
4896
+ for (const name of opts.stopTools) {
4897
+ stopConditions.push(hasToolCall(name));
4898
+ }
4899
+ }
4900
+ let startTime;
4901
+ let firstChunkAt;
4902
+ let stepStartedAt;
4903
+ const onStep = opts.onStep ? (event) => {
4904
+ armWatchdog();
4905
+ const meta = { startedAt: stepStartedAt, firstTokenAt: firstChunkAt };
4906
+ firstChunkAt = void 0;
4907
+ stepStartedAt = nowISO();
4908
+ opts.onStep(event, meta);
4909
+ } : void 0;
4910
+ try {
4911
+ this.hooks.beforeLLM?.({ messageCount: opts.messages.length, messages: opts.messages });
4912
+ startTime = Date.now();
4913
+ stepStartedAt = nowISO();
4914
+ armWatchdog();
4915
+ stream = streamText({
4916
+ model: this.model,
4917
+ system: opts.system,
4918
+ messages: opts.messages,
4919
+ tools,
4920
+ toolChoice: opts.toolChoice,
4921
+ stopWhen: stopConditions.length > 0 ? stopConditions : void 0,
4922
+ abortSignal: callAbort.signal,
4923
+ ...opts.prepareStep && { prepareStep: opts.prepareStep },
4924
+ onStepFinish: onStep,
4925
+ onError: ({ error }) => {
4926
+ opts.onStreamError?.(error);
4927
+ },
4928
+ // SignalEntry entries are trusted system-generated notifications emitted
4929
+ // as `system`-role messages (in-band, order-sensitive). AI SDK v6 warns
4930
+ // on any system message inside `messages` unless this is opted in.
4931
+ allowSystemInMessages: true
4932
+ });
4933
+ } catch (err) {
4934
+ finished = true;
4935
+ clearWatchdog();
4936
+ this.abortController.signal.removeEventListener("abort", forwardAbort);
4937
+ if (this.abortController.signal.aborted) {
4938
+ return { text: "", toolResults: [], usage: void 0 };
4939
+ }
4940
+ throw err;
4941
+ }
4942
+ try {
4943
+ for await (const chunk of stream.textStream) {
4944
+ armWatchdog();
4945
+ if (chunk && firstChunkAt === void 0) firstChunkAt = nowISO();
4946
+ yield chunk;
4947
+ }
4948
+ } catch (err) {
4949
+ if (this.abortController.signal.aborted) {
4950
+ return { text: "", toolResults: [], usage: void 0 };
4951
+ }
4952
+ if (timedOut) {
4953
+ throw new Error(
4954
+ `LLM call idle timeout: no stream progress (text chunk or step finish) for ${idleTimeoutMs}ms`
4955
+ );
4956
+ }
4957
+ if (this.abortController.signal.aborted) {
4958
+ return { text: "", toolResults: [], usage: void 0 };
4959
+ }
4960
+ throw err;
4961
+ } finally {
4962
+ finished = true;
4963
+ clearWatchdog();
4964
+ this.abortController.signal.removeEventListener("abort", forwardAbort);
4965
+ }
4966
+ const duration = Date.now() - startTime;
4967
+ const text = await stream.text;
4968
+ const reasoningText = await stream.reasoningText;
4969
+ const toolResults = await stream.toolResults;
4970
+ const rawUsage = await stream.usage;
4971
+ const finishReason = await stream.finishReason;
4972
+ const steps = await stream.steps;
4973
+ const usage = rawUsage ? { inputTokens: rawUsage.inputTokens ?? 0, outputTokens: rawUsage.outputTokens ?? 0 } : void 0;
4974
+ this.hooks.afterLLM?.({ step, usage, durationMs: duration });
4975
+ return {
4976
+ text,
4977
+ reasoningText,
4978
+ toolResults: toolResults?.map((tr) => ({
4979
+ toolCallId: tr.toolCallId,
4980
+ result: tr.result,
4981
+ isError: tr.isError
4982
+ })),
4983
+ usage,
4984
+ finishReason,
4985
+ steps: steps?.length
4986
+ };
4201
4987
  }
4202
- /**
4203
- * Compact the conversation by summarizing all current entries.
4204
- *
4205
- * Appends a compact marker entry to the conversation (and JSONL file).
4206
- * Does NOT modify or remove any existing entries — storage is append-only.
4207
- *
4208
- * The summarizer receives ALL entries and returns a summary.
4209
- * After compaction, getSystemPrompt() carries the summary and getMessages() returns only the entries appended after the marker.
4210
- *
4211
- * @param meta Optional provenance recorded on the marker (model name).
4212
- */
4213
- async compact(summarizer, meta) {
4214
- const conversation = this.state.get("conversation");
4215
- if (conversation.length < 4) return;
4216
- const fromEntryId = conversation[0]?.uuid;
4217
- const toEntryId = conversation[conversation.length - 1]?.uuid;
4218
- const summary = await summarizer(conversation);
4219
- this.compactSummary = summary;
4220
- this.compactStartIdx = conversation.length;
4221
- this.lastReportedTokens = null;
4222
- const markerTs = nowISO();
4223
- this.state.append("conversation", {
4224
- type: "system" /* System */,
4225
- uuid: generateId("compact"),
4226
- parentUuid: null,
4227
- sessionId: this.state.get("runtimeState.session.id") ?? "",
4228
- timestamp: markerTs,
4229
- subtype: "compact" /* Compact */,
4230
- content: summary,
4231
- // System entries are point-in-time events: startedAt/endedAt share the
4232
- // marker's creation moment.
4233
- startedAt: markerTs,
4234
- endedAt: markerTs,
4235
- compactMeta: {
4236
- summarizedTokenCount: estimateConversationTokens(conversation),
4237
- ...fromEntryId !== void 0 && { fromEntryId },
4238
- ...toEntryId !== void 0 && { toEntryId },
4239
- ...meta?.model !== void 0 && { model: meta.model },
4240
- createdAt: markerTs
4988
+ /** Batch wrapper — iterates streamLoop and returns the final result. */
4989
+ async collectLoop(opts) {
4990
+ const gen = this.streamLoop(opts);
4991
+ let result;
4992
+ while (true) {
4993
+ const { done, value } = await gen.next();
4994
+ if (done) {
4995
+ result = value;
4996
+ break;
4241
4997
  }
4242
- });
4998
+ }
4999
+ return result;
4243
5000
  }
4244
- };
4245
-
4246
- // src/services/agents/worker-state.ts
4247
- import { join as join7 } from "path";
4248
- var WorkerState = class extends BaseWayangState {
4249
- constructor(sessionDir, workerId, logger) {
4250
- const workerDir = join7(sessionDir, "workers", workerId);
4251
- const jsonFile = new JSONFileHelper(join7(workerDir, "runtime-state.json"));
4252
- const jsonlFile = new JSONLFileHelper(join7(workerDir, "conversation.jsonl"));
4253
- const initialData = {
4254
- runtimeState: {
4255
- session: { id: workerId, startedAt: Date.now() },
4256
- task: { id: "", description: "" }
4257
- },
4258
- conversation: [],
4259
- inbox: []
4260
- };
4261
- super(initialData, [
4262
- { path: "runtimeState", helper: jsonFile },
4263
- { path: "conversation", helper: jsonlFile }
4264
- ]);
4265
- this.logger = logger;
4266
- this.jsonFile = jsonFile;
4267
- this.jsonlFile = jsonlFile;
5001
+ abort() {
5002
+ this.abortController.abort();
4268
5003
  }
4269
- logger;
4270
- jsonFile;
4271
- jsonlFile;
4272
- async restore() {
4273
- const runtimeState = this.jsonFile.read();
4274
- if (runtimeState) {
4275
- this.data.runtimeState = runtimeState;
5004
+ };
5005
+ function pauseWatchdogDuringTools(tools, onStart, onEnd) {
5006
+ const wrapped = {};
5007
+ for (const [name, def] of Object.entries(tools ?? {})) {
5008
+ const original = def?.execute;
5009
+ if (typeof original !== "function") {
5010
+ wrapped[name] = def;
5011
+ continue;
4276
5012
  }
4277
- const allEntries = this.jsonlFile.read();
4278
- const compactIdx = allEntries.findLastIndex(isCompactMarker);
4279
- if (compactIdx === -1) {
4280
- if (allEntries.length > 0) {
4281
- this.data.conversation = allEntries;
4282
- }
4283
- } else {
4284
- let budget = RECENT_TOKEN_BUDGET;
4285
- let cutIdx = 0;
4286
- for (let i = compactIdx - 1; i >= 0; i--) {
4287
- budget -= estimateEntryTokens(allEntries[i]);
4288
- if (budget <= 0) {
4289
- cutIdx = i + 1;
4290
- break;
5013
+ wrapped[name] = {
5014
+ ...def,
5015
+ execute: async (...args) => {
5016
+ onStart();
5017
+ try {
5018
+ return await original(...args);
5019
+ } finally {
5020
+ onEnd();
4291
5021
  }
4292
5022
  }
4293
- this.data.conversation = allEntries.slice(cutIdx);
4294
- }
4295
- this.logger.info("WorkerState restored");
5023
+ };
4296
5024
  }
4297
- };
5025
+ return wrapped;
5026
+ }
4298
5027
 
4299
5028
  // src/services/agents/utils/build-assistant-entry.ts
4300
5029
  function buildAssistantEntry(event, sessionId, uuid, extras) {
@@ -4537,18 +5266,39 @@ function controllerMessageContent(content) {
4537
5266
  return `[Controller message] ${content}`;
4538
5267
  }
4539
5268
  function createInboxInjection(deps) {
5269
+ let baseLength = null;
4540
5270
  const injected = [];
4541
5271
  return {
4542
5272
  prepareStep({ messages }) {
4543
5273
  const fresh = deps.drainInbox();
4544
5274
  if (fresh.length === 0 && injected.length === 0) return void 0;
5275
+ if (baseLength === null) baseLength = messages.length;
5276
+ const responseCount = Math.max(0, messages.length - baseLength);
4545
5277
  for (const m of fresh) {
4546
- injected.push({ role: "user", content: controllerMessageContent(m.content) });
5278
+ injected.push({
5279
+ message: { role: "user", content: controllerMessageContent(m.content) },
5280
+ // After the response messages that exist NOW — i.e. at the tail of
5281
+ // the conversation as this step sees it, before this step's reply.
5282
+ insertAfter: responseCount
5283
+ });
4547
5284
  deps.onInject(m.content);
4548
5285
  }
4549
- return { messages: [...messages, ...injected] };
5286
+ const merged = messages.slice(0, baseLength);
5287
+ let cursor = 0;
5288
+ for (const inj of injected) {
5289
+ while (cursor < Math.min(inj.insertAfter, responseCount)) {
5290
+ merged.push(messages[baseLength + cursor]);
5291
+ cursor++;
5292
+ }
5293
+ merged.push(inj.message);
5294
+ }
5295
+ while (cursor < responseCount) {
5296
+ merged.push(messages[baseLength + cursor]);
5297
+ cursor++;
5298
+ }
5299
+ return { messages: merged };
4550
5300
  },
4551
- injectedMessages: () => [...injected]
5301
+ injectedMessages: () => injected.map((i) => i.message)
4552
5302
  };
4553
5303
  }
4554
5304
 
@@ -4566,15 +5316,16 @@ function isInsideWorkspace(resolved, workspace) {
4566
5316
  const rel = relative(workspace, resolved);
4567
5317
  return !rel.startsWith("..") && resolve(workspace, rel) === resolved;
4568
5318
  }
4569
- function truncate(result) {
4570
- if (result.length <= MAX_RESULT_CHARS) return result;
4571
- return result.slice(0, MAX_RESULT_CHARS) + "\n...(truncated)";
5319
+ function truncate(result, maxChars = MAX_RESULT_CHARS) {
5320
+ if (result.length <= maxChars) return result;
5321
+ return result.slice(0, maxChars) + "\n...(truncated)";
4572
5322
  }
4573
- function safeExecute(name, fn) {
4574
- return async (args, options) => {
5323
+ function safeExecute(name, fn, options) {
5324
+ const maxChars = options?.maxResultChars ?? MAX_RESULT_CHARS;
5325
+ return async (args, callOptions) => {
4575
5326
  try {
4576
- const result = await fn(args, options);
4577
- return truncate(typeof result === "string" ? result : JSON.stringify(result));
5327
+ const result = await fn(args, callOptions);
5328
+ return truncate(typeof result === "string" ? result : JSON.stringify(result), maxChars);
4578
5329
  } catch (err) {
4579
5330
  return `[ERROR] ${name}: ${err.message ?? String(err)}`;
4580
5331
  }
@@ -4728,12 +5479,12 @@ Check the "Available workers" section below for current options.
4728
5479
 
4729
5480
  ## How to write a good task description
4730
5481
 
4731
- The worker only sees the task description \u2014 it has NO access to your conversation.
4732
5482
  Write it as a **self-contained instruction**:
4733
5483
  1. What to do (specific, unambiguous)
4734
5484
  2. All context/data the worker needs
4735
5485
  3. Expected output (file path, format, etc.)
4736
5486
  4. Constraints (language, length, style)
5487
+ 5. Acceptance criteria \u2014 what counts as done
4737
5488
 
4738
5489
  Bad: "Write the essay the user asked for"
4739
5490
  Good: "Write a 200-word Chinese prose essay about autumn. Save to sanwen.txt in UTF-8."
@@ -4799,11 +5550,37 @@ Use get_task_detail when you need more information about a specific task:
4799
5550
  - You need to understand what a worker actually did before deciding next steps
4800
5551
  - Checking the error details of a failed task before retrying
4801
5552
 
4802
- The tool shows task metadata (status, result, error) and recent worker conversation entries (tool calls and outputs).
5553
+ The tool shows task metadata (status, result, error) and the worker conversation. The conversation comes from the worker's live in-memory state while it runs, and from its on-disk transcript after the worker ended (marked [from transcript]). The conversation is paginated and ALWAYS says what was cut:
5554
+ - Pagination semantics: \`offset\` skips N entries counted back from the newest; output is ordered oldest\u2192newest; type filtering is applied BEFORE pagination.
5555
+ - \`limit\` (default 10, counted back from the newest), \`offset\` (default 0, skip the newest N), \`maxChars\` (per-entry cap, default 200), \`full=true\` (complete text, all entries).
5556
+ - A meta line reports \`totalEntries / returnedEntries / offset / truncated\`; a \`[truncated: ...]\` line appears whenever anything was cut, and each cut entry carries \`(truncated: 200/4642 chars)\`.
5557
+ - NEVER conclude that a worker "stopped mid-sentence" from a truncated entry. If an entry is marked truncated, re-read it with full=true (or page back with offset) before judging.
5558
+
5559
+ ## Reading a worker's full transcript (read_worker_conversation)
5560
+
5561
+ read_worker_conversation(workerId, limit?, offset?, maxChars?, full?, type?) reads a worker's conversation with a layered source: while the engine still tracks the worker (running or idle), its live in-memory conversation is returned (meta \`source="memory"\`); after the worker finished/failed/was cancelled or disposed, its append-only on-disk \`conversation.jsonl\` is read instead (meta \`source="disk"\`). So it works for running AND already-finished workers, including after cancel_task.
5562
+ - Pagination semantics: \`offset\` skips N entries counted back from the newest; output is ordered oldest\u2192newest; type filtering is applied BEFORE pagination.
5563
+ - Same per-entry truncation markers as get_task_detail; entries print in chronological order.
5564
+ - \`type\` filters: user / assistant / system / signal / tool ("tool" = assistant entries that made tool calls).
5565
+ - A \`[note: last entry is incomplete \u2014 the transcript is still being written]\` line means the newest entry was mid-append when you read it: re-read in a moment instead of treating the tail as the final state. \`readWindowTruncated\` in the meta means older entries exist beyond the read window's byte cap \u2014 page back with offset.
5566
+ - Use it to see what a worker actually produced before you report, retry, or cancel.
5567
+
5568
+ ## Checking whether a worker is alive (get_worker_status)
5569
+
5570
+ get_worker_status(workerId?) \u2014 with an id returns that worker's status object, without one returns all current workers.
5571
+ Fields: workerId, type, taskId, status, runningForMs, lastActivityAt (epoch ms of the newest transcript entry) with lastActivityAgoMs (how long ago that was), currentTool, streamEnded, resultReceived, idleSince.
5572
+ Use it to tell "still working" from "stuck / already finished but not released":
5573
+ - Many minutes without a new transcript entry and no current tool \u2192 likely stuck: inspect with read_worker_conversation, then cancel_task.
5574
+ - resultReceived=true but streamEnded=false / status still running \u2192 the worker is in its wrap-up; wait or inspect instead of cancelling.
5575
+ - Unknown worker ids return a not-found message (never an exception).
4803
5576
 
4804
5577
  ## Sending messages to workers (chat_worker)
4805
5578
 
4806
- Use chat_worker(workerId, message) to send guidance to a running worker. The worker will see the message next time it calls check_controller_messages.
5579
+ Use chat_worker(workerId, message) to send guidance to a running worker.
5580
+ The worker reads it at its next step (puppet) or turn boundary (third-party) \u2014 a tool call in progress is NEVER interrupted; an idle multi-stage worker reads it when its next stage starts.
5581
+ Delivery is blind: "queued" only means the message sits in the worker's inbox until that boundary. A stuck worker never reads it.
5582
+ A third-party worker that already finished its session rejects the message with an explicit [ERROR] \u2014 inspect its output instead of retrying.
5583
+ Before chatting, check get_worker_status; if the worker is stuck, cancel_task is the honest action (a queued message will not revive it).
4807
5584
  Use sparingly \u2014 only when you need to:
4808
5585
  - Correct a worker that is going in the wrong direction
4809
5586
  - Provide additional context the worker did not have
@@ -4840,7 +5617,8 @@ Types:
4840
5617
  Guidelines:
4841
5618
  - Prefer confirm and select over text. Less typing = better experience.
4842
5619
  - Keep messages short and specific.
4843
- - For select, provide 2-5 clear options. Not too many.`
5620
+ - For select, provide 2-5 clear preset options \u2014 an "Other" choice is appended automatically so the user can always type a custom answer, so never add your own catch-all option. Custom answers come back prefixed with "[Other] ".
5621
+ - Set allowOther=false only when the answer must be one of the preset options.`
4844
5622
  );
4845
5623
  var SIGNAL_HANDLING = section(
4846
5624
  "Handling signals",
@@ -4876,8 +5654,9 @@ Explain the error. Suggest a retry or alternative.
4876
5654
  ## [HEARTBEAT] \u2014 periodic check-in while workers are running
4877
5655
 
4878
5656
  You were woken up because workers are running but no events arrived for a while.
4879
- Review the worker status in the heartbeat message:
4880
- - A worker has been running unusually long \u2192 inform the user proactively
5657
+ Review the worker status in the heartbeat message (runningForMs, last activity, current tool):
5658
+ - A worker has been running unusually long AND its last activity is old (or it shows no tool) \u2192 inspect it: get_worker_status then read_worker_conversation. If it is genuinely stuck, cancel_task and report honestly.
5659
+ - A worker has been running unusually long but activity is recent \u2192 inform the user proactively
4881
5660
  - Everything looks normal \u2192 call skip_reply()
4882
5661
 
4883
5662
  ## [PERMISSION REQUEST ...] \u2014 worker needs approval
@@ -4900,6 +5679,39 @@ You resumed a previous session that had unfinished tasks. Workers were NOT autom
4900
5679
 
4901
5680
  Do NOT blindly re-create everything. The user may have moved on. Prefer to summarize and ask unless the intent is obvious.`
4902
5681
  );
5682
+ var WORKING_PRINCIPLES = section(
5683
+ "Working principles",
5684
+ `\u3010\u4EFB\u52A1\u62C6\u5206\u3011
5685
+ - \u4E00\u4E2A\u4EFB\u52A1\u53EA\u4EA4\u4ED8\u4E00\u4E2A\u53EF\u72EC\u7ACB\u9A8C\u6536\u7684\u7ED3\u679C\uFF1B\u4E0D\u8981\u628A\u8BBE\u8BA1\u3001\u5B9E\u73B0\u3001\u6D4B\u8BD5\u585E\u8FDB\u540C\u4E00\u6BB5\u3002
5686
+ - \u5148\u505A\u80FD\u88AB\u5355\u6D4B\u65AD\u8A00\u7684\u6838\u5FC3\u903B\u8F91\uFF0C\u628A\u8FB9\u754C\uFF08\u5BBD\u5B57\u7B26\u3001\u4EE3\u7406\u5BF9\u3001\u6298\u884C\u4F4D\u7F6E\u4E4B\u7C7B\uFF09\u56FA\u5316\u6210\u7528\u4F8B\uFF0C\u518D\u53E0\u52A0\u96C6\u6210\u4E0E\u4EA4\u4E92\u5C42\u3002
5687
+ - \u4EFB\u52A1\u63CF\u8FF0\u5FC5\u987B\u81EA\u5305\u542B\uFF1A\u80CC\u666F\u3001\u76EE\u6807\u3001\u8F93\u5165\u8F93\u51FA\u3001\u7EA6\u675F\u3001\u9A8C\u6536\u6807\u51C6\u3002\u6267\u884C\u65B9\u770B\u4E0D\u5230\u4F60\u7684\u5BF9\u8BDD\u3002
5688
+
5689
+ \u3010\u5206\u5DE5\u4E0E\u5BA1\u6838\u3011
5690
+ - \u8BBE\u8BA1\u5224\u65AD\u4E0E\u673A\u68B0\u5B9E\u73B0\u5206\u5F00\u6D3E\uFF1A\u8BBE\u8BA1\u65B9\u5148\u5B9E\u6D4B\u5173\u952E\u884C\u4E3A\uFF0C\u4EA7\u51FA\u300C\u6539\u54EA\u4E2A\u6587\u4EF6\u3001\u51FD\u6570\u7B7E\u540D\u3001\u6570\u636E\u7ED3\u6784\u3001\u8FB9\u754C\u60C5\u51B5\u3001\u6D4B\u8BD5\u6E05\u5355\u300D\u7684\u65B9\u6848\uFF1B\u6267\u884C\u65B9\u7167\u65B9\u6848\u7F16\u7801\uFF0C\u4E0D\u505A\u8BBE\u8BA1\u51B3\u7B56\u3002
5691
+ - \u65B9\u6848\u91CC\u8981\u663E\u5F0F\u5199\u51FA\u300C\u4E0D\u8981\u505A\u4EC0\u4E48\u300D\uFF0C\u5426\u5219\u6267\u884C\u65B9\u4F1A\u987A\u624B\u91CD\u6784\u65E0\u5173\u4EE3\u7801\u3002
5692
+ - \u5B9E\u73B0\u5B8C\u6210\u540E\u8BA9\u8BBE\u8BA1\u65B9\u56DE\u5BA1\uFF0C\u53EA\u67E5 diff\u3001\u4E0D\u53D8\u91CF\u4E0E\u56DE\u5F52\uFF0C\u4E0D\u63D0\u98CE\u683C\u504F\u597D\u3002
5693
+
5694
+ \u3010\u5E76\u884C\u3011
5695
+ - \u53EA\u6709\u72EC\u7ACB\u5DE5\u4F5C\u533A\u624D\u80FD\u5E76\u884C\uFF1B\u540C\u4E00\u5DE5\u4F5C\u533A\u5185\u7684\u5199\u5165\u4E0E\u63D0\u4EA4\u5FC5\u987B\u4E32\u884C\u3002
5696
+ - \u8DE8\u53D8\u4F53\u642C\u8FD0\u6539\u52A8\u65F6\u53EA\u642C\u6539\u52A8\u672C\u8EAB\uFF1A\u5265\u79BB\u6765\u6E90\u5206\u652F\u7684\u9644\u5E26\u4F9D\u8D56\uFF0C\u5408\u6D41\u4EE5\u76EE\u6807\u57FA\u5EA7\u7684\u8BED\u4E49\u4E3A\u51C6\u3002
5697
+
5698
+ \u3010\u5224\u65AD\u4E0E\u5904\u7F6E\u3011
5699
+ - \u4E0D\u8981\u51ED\u300C\u6CA1\u6709\u65B0\u8F93\u51FA\u300D\u5224\u65AD\u6B7B\u6D3B\u3002\u5148\u53D6\u8BC1\uFF1A\u6267\u884C\u75D5\u8FF9\u6700\u540E\u4E00\u6761\u662F\u4EC0\u4E48\u3001\u6709\u6CA1\u6709\u60AC\u7A7A\u7684\u8C03\u7528\u3001\u8D44\u6E90\u5360\u7528\u662F\u5426\u5728\u589E\u957F\uFF0C\u518D\u51B3\u5B9A\u7B49\u5F85\u3001\u62C6\u5C0F\u91CD\u6D3E\u8FD8\u662F\u53D6\u6D88\u3002
5700
+ - \u5DF2\u7ECF\u9677\u5165\u5FAA\u73AF\u7684\uFF08\u53CD\u590D\u88AB max_tokens \u622A\u65AD\u3001\u957F\u65F6\u95F4\u65E0\u4EA7\u51FA\uFF09\u7B49\u4E0D\u51FA\u6765\uFF0C\u53CA\u65F6\u62C6\u5C0F\u6216\u6362\u7B56\u7565\u3002
5701
+ - \u8FD0\u884C\u65F6\u7684\u4EA4\u4E92\u8BED\u4E49\u662F\u7248\u672C\u76F8\u5173\u7684\uFF08\u4F8B\u5982\u4E2D\u9014\u6D88\u606F\u662F\u6253\u65AD\u5F53\u524D\u56DE\u5408\u8FD8\u662F\u5728\u8FB9\u754C\u4EA4\u4ED8\uFF09\uFF0C\u4F9D\u8D56\u524D\u5148\u5728\u5F53\u524D\u7248\u672C\u5B9E\u6D4B\u3002
5702
+
5703
+ \u3010\u9A8C\u8BC1\u3011
5704
+ - \u6BCF\u6B21\u6539\u52A8\u8DD1\u540C\u4E00\u5957\u95E8\u69DB\uFF08\u7C7B\u578B\u68C0\u67E5\u3001lint\u3001\u6D4B\u8BD5\u3001\u6784\u5EFA\uFF09\uFF0C\u8303\u56F4\u968F\u5F71\u54CD\u9762\u4F38\u7F29\uFF1A\u5C0F\u6539\u8DD1\u76F8\u5173\u5B50\u96C6\uFF0C\u5927\u6539\u8DD1\u5168\u91CF\u3002
5705
+ - \u91CD\u6392\u5386\u53F2\u3001\u8DE8\u5206\u652F\u642C\u8FD0\u540E\uFF0C\u7528\u7B26\u53F7\u68C0\u7D22\u4E0E tree hash \u8FD9\u7C7B\u673A\u68B0\u8BC1\u636E\u9A8C\u8BC1\u7B49\u4EF7\uFF0C\u4E0D\u8981\u8089\u773C\u590D\u67E5\u3002
5706
+ - \u4EA4\u4E92\u7C7B\u6539\u52A8\u8981\u6709\u4E00\u6B21\u771F\u5B9E\u8FD0\u884C\u7684\u7AEF\u5230\u7AEF\u590D\u6F14\uFF1B\u5355\u6D4B\u5168\u7EFF\u4E0D\u7B49\u4E8E\u5B8C\u6210\u3002
5707
+
5708
+ \u3010\u9519\u8BEF\u6A21\u5F0F\u3011
5709
+ - \u6DF7\u6742\u6D3E\u5DE5 \u2014\u2014 \u628A\u8BBE\u8BA1\u3001\u5B9E\u73B0\u3001\u6D4B\u8BD5\u585E\u8FDB\u4E00\u4E2A\u4EFB\u52A1\uFF0C\u7ED3\u679C\u957F\u65F6\u95F4\u7A7A\u8F6C\u3001\u6700\u7EC8\u4EA4\u4ED8\u4E3A\u7A7A\u3002
5710
+ - \u540C\u533A\u4E92\u5377 \u2014\u2014 \u8BA9\u4E24\u4E2A\u6267\u884C\u8005\u5728\u540C\u4E00\u5DE5\u4F5C\u533A\u5E76\u53D1\u63D0\u4EA4\uFF0C\u4E92\u76F8\u628A\u5BF9\u65B9\u7684\u6539\u52A8\u5377\u8FDB\u81EA\u5DF1\u7684 commit\u3002
5711
+ - \u9759\u9ED8\u5224\u6B7B \u2014\u2014 \u51ED\u8F93\u51FA\u9759\u9ED8\u5224\u65AD\u6B7B\u6D3B\uFF1A\u628A\u5728\u8DD1\u957F\u4EFB\u52A1\u5F53\u6210\u5361\u6B7B\u6740\u6389\uFF0C\u6216\u628A\u6D3B\u9501\u5F53\u6210\u6162\u4EFB\u52A1\u6B7B\u7B49\u3002
5712
+ - \u7EFF\u706F\u5373\u5B8C\u6210 \u2014\u2014 \u5355\u6D4B\u6216\u6D41\u7A0B\u5168\u7EFF\u5C31\u5BA3\u5E03\u5B8C\u6210\uFF1B\u5076\u53D1\u5931\u8D25\u590D\u8DD1\u4E00\u6B21\u901A\u8FC7\u5C31\u5F53\u6CA1\u53D1\u751F\uFF0C\u4E0D\u505A\u8BB0\u5F55\u3002
5713
+ - \u96F6\u788E\u53D6\u8BC1 \u2014\u2014 \u628A\u4E00\u6B21\u53D6\u8BC1\u62C6\u6210\u51E0\u5341\u6761\u96F6\u788E\u53EA\u8BFB\u547D\u4EE4\u9010\u6761\u7B49\u6279\u51C6\uFF0C\u6392\u969C\u88AB\u6D41\u7A0B\u672C\u8EAB\u62D6\u6B7B\u3002`
5714
+ );
4903
5715
  var HARD_CONSTRAINTS = section(
4904
5716
  "Hard constraints",
4905
5717
  `- NEVER execute commands or write files yourself. Delegate via add_task\uFF08worker will execute\uFF09.
@@ -4925,6 +5737,7 @@ function buildControllerSystemPrompt(ctx, skills) {
4925
5737
  TOOL_USAGE,
4926
5738
  PLANNING_TOOLS,
4927
5739
  SIGNAL_HANDLING,
5740
+ WORKING_PRINCIPLES,
4928
5741
  HARD_CONSTRAINTS,
4929
5742
  buildWorkerList(ctx.config.workers),
4930
5743
  buildSkillCatalog(skills),
@@ -4932,6 +5745,54 @@ function buildControllerSystemPrompt(ctx, skills) {
4932
5745
  );
4933
5746
  }
4934
5747
 
5748
+ // src/services/agents/prompts/project-rules.ts
5749
+ import { existsSync as existsSync6, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
5750
+ import { basename as basename2, dirname as dirname4, join as join9, resolve as resolve2 } from "path";
5751
+ var RULE_FILE_NAMES = ["AGENTS.md", "CLAUDE.md"];
5752
+ var MAX_LEVELS = 5;
5753
+ var PROJECT_RULES_MAX_LENGTH = 8e3;
5754
+ function findProjectRules(startDir) {
5755
+ let current = resolve2(startDir);
5756
+ for (let level = 0; level < MAX_LEVELS; level++) {
5757
+ const found = readRuleFile(current);
5758
+ if (found) return found;
5759
+ if (existsSync6(join9(current, ".git"))) break;
5760
+ const parent = dirname4(current);
5761
+ if (parent === current) break;
5762
+ current = parent;
5763
+ }
5764
+ return null;
5765
+ }
5766
+ function readRuleFile(dir) {
5767
+ for (const name of RULE_FILE_NAMES) {
5768
+ const path7 = join9(dir, name);
5769
+ try {
5770
+ if (!statSync2(path7).isFile()) continue;
5771
+ const content = readFileSync6(path7, "utf8");
5772
+ if (!content.trim()) continue;
5773
+ return { path: path7, content };
5774
+ } catch {
5775
+ }
5776
+ }
5777
+ return null;
5778
+ }
5779
+ function buildProjectRulesSection(rules) {
5780
+ if (!rules) return "";
5781
+ const name = basename2(rules.path);
5782
+ const truncated = rules.content.length > PROJECT_RULES_MAX_LENGTH;
5783
+ const content = truncated ? `${rules.content.slice(0, PROJECT_RULES_MAX_LENGTH).trimEnd()}
5784
+
5785
+ ...truncated (rules file exceeded ${PROJECT_RULES_MAX_LENGTH} characters)` : rules.content.trimEnd();
5786
+ return section(
5787
+ `Project rules (${name})`,
5788
+ `Rules from this project's ${name}. They apply to all work on the task \u2014 follow them unless the task description explicitly overrides them.
5789
+
5790
+ Source: ${rules.path}
5791
+
5792
+ ${content}`
5793
+ );
5794
+ }
5795
+
4935
5796
  // src/services/agents/prompts/worker-prompt.ts
4936
5797
  var IDENTITY2 = section(
4937
5798
  "Identity",
@@ -5083,6 +5944,10 @@ function buildWorkerSystemPrompt(ctx, skills) {
5083
5944
  TOOL_USAGE2,
5084
5945
  EXECUTION_STRATEGY,
5085
5946
  PROJECT_CONVENTIONS,
5947
+ // Project rules discovered near the task workspace (AGENTS.md, falling
5948
+ // back to CLAUDE.md). Empty when no rules file exists — assemble() drops
5949
+ // the section, so no empty paragraph leaks into the prompt.
5950
+ buildProjectRulesSection(findProjectRules(ctx.workspaceDir ?? process.cwd())),
5086
5951
  QUALITY_RULES,
5087
5952
  HARD_CONSTRAINTS2,
5088
5953
  CONTROLLER_COMMUNICATION,
@@ -5261,6 +6126,15 @@ var WorkerAgent = class extends BaseAgent {
5261
6126
  getState() {
5262
6127
  return this.state;
5263
6128
  }
6129
+ /**
6130
+ * Live run flags (issue #16). A puppet worker has no SDK result/stream
6131
+ * concept: `resultReceived` means the model already called done/fail, and
6132
+ * `streamEnded` is not tracked (null), so status readers fall back to the
6133
+ * transcript / task state instead of guessing.
6134
+ */
6135
+ getLiveStatus() {
6136
+ return { resultReceived: this._terminalResult !== null, streamEnded: null };
6137
+ }
5264
6138
  /** Get conversation entries. */
5265
6139
  getConversation() {
5266
6140
  return this.state.get("conversation") ?? [];
@@ -5272,7 +6146,11 @@ var WorkerAgent = class extends BaseAgent {
5272
6146
  getSnapshot(path7) {
5273
6147
  return this.state.get(path7);
5274
6148
  }
5275
- /** Accept a message from the controller — writes to internal inbox. */
6149
+ /**
6150
+ * Accept a message from the controller — writes to internal inbox. The
6151
+ * puppet reads it at its next LLM step, so a running (or idle) worker
6152
+ * always accepts.
6153
+ */
5276
6154
  acceptMessage(message) {
5277
6155
  this.state.append("inbox", {
5278
6156
  id: generateId("msg"),
@@ -5280,6 +6158,7 @@ var WorkerAgent = class extends BaseAgent {
5280
6158
  timestamp: Date.now()
5281
6159
  });
5282
6160
  this.logger.info({ workerId: this.id }, "Controller message accepted");
6161
+ return true;
5283
6162
  }
5284
6163
  /** Unread controller messages (non-destructive) — engine reports these when the run ends. */
5285
6164
  pendingMessages() {
@@ -5312,11 +6191,61 @@ var WorkerAgent = class extends BaseAgent {
5312
6191
  message: { role: "user", content: controllerMessageContent(content) }
5313
6192
  }, turnEnv));
5314
6193
  }
5315
- };
5316
-
5317
- // src/services/agents/claude-code-worker.ts
5318
- import { query, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
5319
- import { z as z2 } from "zod";
6194
+ };
6195
+
6196
+ // src/services/agents/claude-code-worker.ts
6197
+ import { query, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
6198
+ import { z as z2 } from "zod";
6199
+
6200
+ // src/services/agents/utils/claude-tool-records.ts
6201
+ var CLAUDE_TOOL_RESULT_STORE_CHARS = 2e3;
6202
+ var CLAUDE_TOOL_ARGS_STORE_CHARS = 8e3;
6203
+ function truncateForStorage(text, maxChars) {
6204
+ if (text.length <= maxChars) return text;
6205
+ return `${text.slice(0, maxChars)}... [truncated, ${text.length} chars]`;
6206
+ }
6207
+ function toolUseBlockToRecord(block, startedAt) {
6208
+ return {
6209
+ toolCallId: block.id,
6210
+ toolName: block.name,
6211
+ arguments: truncateForStorage(toolPayloadText(block.input) || "{}", CLAUDE_TOOL_ARGS_STORE_CHARS),
6212
+ status: "running",
6213
+ startedAt
6214
+ };
6215
+ }
6216
+ function flattenToolResultContent(content) {
6217
+ if (content === void 0 || content === null) return "";
6218
+ if (typeof content === "string") return content;
6219
+ if (!Array.isArray(content)) return toolPayloadText(content);
6220
+ const parts = [];
6221
+ for (const block of content) {
6222
+ if (block && typeof block === "object" && "type" in block) {
6223
+ const b = block;
6224
+ if (b.type === "text" && typeof b.text === "string") {
6225
+ parts.push(b.text);
6226
+ } else {
6227
+ parts.push(`[${String(b.type)} block]`);
6228
+ }
6229
+ } else if (typeof block === "string") {
6230
+ parts.push(block);
6231
+ }
6232
+ }
6233
+ return parts.join("\n");
6234
+ }
6235
+ function toolResultBlockToRecord(block, toolName, endedAt) {
6236
+ const isError = block.is_error === true;
6237
+ return {
6238
+ toolCallId: block.tool_use_id,
6239
+ toolName,
6240
+ output: {
6241
+ type: "text",
6242
+ value: truncateForStorage(flattenToolResultContent(block.content), CLAUDE_TOOL_RESULT_STORE_CHARS)
6243
+ },
6244
+ ...isError && { isError: true, errorType: "ToolError" },
6245
+ status: isError ? "failed" : "succeeded",
6246
+ endedAt
6247
+ };
6248
+ }
5320
6249
 
5321
6250
  // src/infra/blocking-requests.ts
5322
6251
  var BlockingRequestMap = class {
@@ -5329,14 +6258,14 @@ var BlockingRequestMap = class {
5329
6258
  if (this.pending.has(id)) {
5330
6259
  throw new Error(`BlockingRequestMap: duplicate wait id "${id}"`);
5331
6260
  }
5332
- return new Promise((resolve11) => {
6261
+ return new Promise((resolve12) => {
5333
6262
  const settle = (value) => {
5334
6263
  this.pending.delete(id);
5335
- resolve11(value);
6264
+ resolve12(value);
5336
6265
  };
5337
6266
  const timer = setTimeout(() => {
5338
6267
  this.pending.delete(id);
5339
- resolve11(makeTimeoutValue());
6268
+ resolve12(makeTimeoutValue());
5340
6269
  }, timeoutMs);
5341
6270
  if (typeof timer === "object" && timer && "unref" in timer) {
5342
6271
  timer.unref();
@@ -5373,17 +6302,35 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
5373
6302
  logger;
5374
6303
  abortController = new AbortController();
5375
6304
  _terminalResult = null;
6305
+ /** True once the SDK message stream of the current run has closed (issue #16). */
6306
+ _streamEnded = false;
5376
6307
  // --- Controller messaging via streamInput ---
5377
6308
  _messageQueue = [];
5378
6309
  _messageResolve = null;
5379
- /** Active SDK query — interrupted to deliver controller messages promptly. */
5380
- _activeQuery = null;
5381
6310
  /**
5382
- * Interrupt requested while a permission was pending — delivered right
5383
- * after the permission settles (interrupting mid-permission is unproven
5384
- * SDK territory; see docs/develop/v0.6-design.md §3).
6311
+ * True while run() is executing. Controller messages are rejected once the
6312
+ * run's input has closed (see `_inputClosed`), but idle multi-stage workers
6313
+ * (no run in flight) keep accepting — the next stage folds them into its
6314
+ * opening prompt.
6315
+ */
6316
+ _runInFlight = false;
6317
+ /**
6318
+ * True once the prompt iterator reported done for the current run: the
6319
+ * session can no longer take input (a turn result arrived with nothing
6320
+ * queued, or the run was aborted). Further acceptMessage calls fail
6321
+ * explicitly instead of pretending to queue.
6322
+ */
6323
+ _inputClosed = false;
6324
+ // --- Tool-call recording (see utils/claude-tool-records.ts) ---
6325
+ /**
6326
+ * Assistant steps buffered while their tool results are still in flight.
6327
+ * The SDK stream splits one LLM step across two messages (assistant
6328
+ * tool_use → user tool_result), but the schema stores a step atomically on
6329
+ * one AssistantEntry and storage is append-only — so the entry waits in
6330
+ * memory until its results arrive (or the stream ends). FIFO order is kept:
6331
+ * only the head is flushed, so the file order always matches arrival order.
5385
6332
  */
5386
- _interruptQueued = false;
6333
+ _pendingToolSteps = [];
5387
6334
  // --- ask_controller via in-process SDK MCP server ---
5388
6335
  _askController = null;
5389
6336
  // --- Permission control via canUseTool ---
@@ -5392,31 +6339,17 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
5392
6339
  /** Exposed for engine to resolve pending permission requests. */
5393
6340
  permissionHandler = {
5394
6341
  resolvePermission: (requestId, approved, reason) => {
5395
- const ok = this._pendingPermissions.resolve(
6342
+ return this._pendingPermissions.resolve(
5396
6343
  requestId,
5397
6344
  approved ? { behavior: "allow" } : { behavior: "deny", message: reason ?? "Denied by controller" }
5398
6345
  );
5399
- if (ok) this.afterPermissionSettled();
5400
- return ok;
5401
6346
  },
5402
6347
  cleanup: () => {
5403
- this._interruptQueued = false;
5404
6348
  this._pendingPermissions.cleanupAll(() => ({ behavior: "deny", message: "Worker aborted" }));
5405
6349
  },
5406
6350
  wrappedTools: {}
5407
6351
  // not used for ClaudeCodeWorker
5408
6352
  };
5409
- /**
5410
- * A permission settled (approved / denied / timed out). If a controller
5411
- * message was waiting for the permission window to close, deliver its
5412
- * interrupt now — this runs on BOTH the resolve and the timeout path.
5413
- */
5414
- afterPermissionSettled() {
5415
- if (this._interruptQueued && this._pendingPermissions.size === 0) {
5416
- this._interruptQueued = false;
5417
- this.requestInterrupt();
5418
- }
5419
- }
5420
6353
  // Signal context set during run()
5421
6354
  _signalQueue = null;
5422
6355
  _runContext = null;
@@ -5463,6 +6396,10 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
5463
6396
  }
5464
6397
  async run(task, _tools, onProgress) {
5465
6398
  this._terminalResult = null;
6399
+ this._streamEnded = false;
6400
+ this._inputClosed = false;
6401
+ this._runInFlight = true;
6402
+ this._pendingToolSteps = [];
5466
6403
  this.state.set("runtimeState.task", { id: task.id, description: task.description });
5467
6404
  const turnEnv = this.buildTurnEnv(task);
5468
6405
  this._currentTurnEnv = turnEnv;
@@ -5520,18 +6457,20 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
5520
6457
  prompt: this.buildPromptIterable(task.description),
5521
6458
  options
5522
6459
  });
5523
- this._activeQuery = stream;
5524
6460
  try {
5525
6461
  for await (const message of stream) {
5526
6462
  if (this.abortController.signal.aborted) break;
5527
6463
  this.processStreamMessage(message, onProgress);
5528
- if (this._terminalResult) break;
5529
6464
  }
5530
6465
  } finally {
5531
- this._activeQuery = null;
6466
+ this._streamEnded = true;
6467
+ this._inputClosed = true;
6468
+ this.clearPendingProgress();
6469
+ this.flushAllToolSteps();
5532
6470
  }
5533
6471
  } catch (err) {
5534
6472
  this.logger.error({ err, taskId: task.id }, "ClaudeCodeWorker query error");
6473
+ this._inputClosed = true;
5535
6474
  if (!this._terminalResult) {
5536
6475
  this._terminalResult = {
5537
6476
  status: "failed",
@@ -5558,6 +6497,7 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
5558
6497
  startedAt: resultTs,
5559
6498
  endedAt: resultTs
5560
6499
  }, turnEnv));
6500
+ this._runInFlight = false;
5561
6501
  return this._terminalResult;
5562
6502
  }
5563
6503
  /** Set signal context for permission requests. Called by engine before run(). */
@@ -5568,6 +6508,7 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
5568
6508
  }
5569
6509
  abort() {
5570
6510
  this.abortController.abort();
6511
+ this.clearPendingProgress();
5571
6512
  this.permissionHandler.cleanup();
5572
6513
  this.flushMessageQueue();
5573
6514
  }
@@ -5575,6 +6516,14 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
5575
6516
  getState() {
5576
6517
  return this.state;
5577
6518
  }
6519
+ /**
6520
+ * Live run flags (issue #16): `resultReceived` flips as soon as the SDK result
6521
+ * message lands, `streamEnded` when the message loop exits. The gap between
6522
+ * them is exactly the #15 window where the controller saw nothing.
6523
+ */
6524
+ getLiveStatus() {
6525
+ return { resultReceived: this._terminalResult !== null, streamEnded: this._streamEnded };
6526
+ }
5578
6527
  /** Get conversation entries. */
5579
6528
  getConversation() {
5580
6529
  return this.state.get("conversation") ?? [];
@@ -5586,34 +6535,36 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
5586
6535
  getSnapshot(path7) {
5587
6536
  return this.state.get(path7);
5588
6537
  }
5589
- /** Accept a message from the controller — injects into running session via streamInput. */
6538
+ /**
6539
+ * Accept a message from the controller. The message is queued and handed
6540
+ * to the SDK only when it pulls the next prompt: the CLI parks pulled
6541
+ * prompts in an internal queue that it drains strictly BETWEEN turns, so
6542
+ * delivery always lands on a turn boundary — a tool call in progress is
6543
+ * never cut off (the v0.6 interrupt delivery did exactly that and killed
6544
+ * the session with an orphaned tool_use; see docs/develop/v0.6-design.md
6545
+ * §6.1 — the hazard is any unclosed tool_use, not just a pending
6546
+ * permission).
6547
+ *
6548
+ * Returns false when delivery is impossible: the run's input has closed
6549
+ * (final result received) or the worker was aborted. Idle multi-stage
6550
+ * workers (no run in flight) always accept — the next stage folds the
6551
+ * message into its opening prompt.
6552
+ */
5590
6553
  acceptMessage(message) {
6554
+ if (this.abortController.signal.aborted) return false;
6555
+ if (this._runInFlight && this._inputClosed) return false;
5591
6556
  this._messageQueue.push(message);
5592
6557
  if (this._messageResolve) {
5593
6558
  this._messageResolve();
5594
6559
  this._messageResolve = null;
5595
6560
  }
5596
- if ((this.config.interruptOnMessage ?? "immediate") === "immediate") {
5597
- this.requestInterrupt();
5598
- }
5599
6561
  this.logger.info({ workerId: this.id }, "Controller message accepted");
6562
+ return true;
5600
6563
  }
5601
6564
  /** Unread controller messages (non-destructive) — engine reports these when the run ends. */
5602
6565
  pendingMessages() {
5603
6566
  return [...this._messageQueue];
5604
6567
  }
5605
- /** Interrupt the active query unless a permission is pending (queued until it settles). */
5606
- requestInterrupt() {
5607
- if (this.abortController.signal.aborted || !this._activeQuery) return;
5608
- if (this._pendingPermissions.size > 0) {
5609
- this._interruptQueued = true;
5610
- return;
5611
- }
5612
- if (typeof this._activeQuery.interrupt !== "function") return;
5613
- this._activeQuery.interrupt().catch((err) => {
5614
- this.logger.warn({ workerId: this.id, err: String(err) }, "interrupt() failed");
5615
- });
5616
- }
5617
6568
  /**
5618
6569
  * Provide the engine-backed ask_controller implementation. Once set, the
5619
6570
  * worker exposes it to Claude Code as an in-process SDK MCP tool.
@@ -5651,9 +6602,7 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
5651
6602
  ...this._turnLineage
5652
6603
  });
5653
6604
  return this._pendingPermissions.wait(requestId, _ClaudeCodeWorker.PERMISSION_TIMEOUT_MS, () => {
5654
- const value = { behavior: "deny", message: "Permission request timed out" };
5655
- this.afterPermissionSettled();
5656
- return value;
6605
+ return { behavior: "deny", message: "Permission request timed out" };
5657
6606
  });
5658
6607
  }
5659
6608
  // --- Prompt as AsyncIterable (supports follow-up messages) ---
@@ -5680,13 +6629,22 @@ ${leftoverText}` : `${buildThirdPartyPrompt(this.skills)}${initialPrompt}`;
5680
6629
  };
5681
6630
  }
5682
6631
  while (true) {
5683
- if (this.abortController.signal.aborted || this._terminalResult) {
6632
+ if (this.abortController.signal.aborted) {
6633
+ this._inputClosed = true;
5684
6634
  return { value: void 0, done: true };
5685
6635
  }
5686
- if (this._messageQueue.length > 0) break;
5687
- await new Promise((resolve11) => {
5688
- this._messageResolve = resolve11;
5689
- });
6636
+ if (this._terminalResult) {
6637
+ if (this._messageQueue.length === 0) {
6638
+ this._inputClosed = true;
6639
+ return { value: void 0, done: true };
6640
+ }
6641
+ } else if (this._messageQueue.length === 0) {
6642
+ await new Promise((resolve12) => {
6643
+ this._messageResolve = resolve12;
6644
+ });
6645
+ continue;
6646
+ }
6647
+ break;
5690
6648
  }
5691
6649
  const msg = this._messageQueue.shift();
5692
6650
  return {
@@ -5751,25 +6709,49 @@ controller: ${answer}`,
5751
6709
  processStreamMessage(message, onProgress) {
5752
6710
  if (message.type === "assistant") {
5753
6711
  this.handleAssistantMessage(message, onProgress);
6712
+ } else if (message.type === "user") {
6713
+ this.handleUserMessage(message);
5754
6714
  } else if (message.type === "result") {
5755
6715
  this.handleResultMessage(message);
5756
6716
  }
5757
6717
  }
6718
+ /** Append one entry to the conversation under the current turn envelope. */
6719
+ appendConversationEntry(entry) {
6720
+ this.state.append("conversation", applyTurnEnvelope(entry, this._currentTurnEnv ?? void 0));
6721
+ }
6722
+ /**
6723
+ * Drop a debounced progress delivery: cancel the timer and the buffered
6724
+ * text. Called when a result supersedes the progress, when the run is
6725
+ * aborted, and when the stream loop exits (all paths — the run is over, so
6726
+ * nothing may be delivered afterwards).
6727
+ */
6728
+ clearPendingProgress() {
6729
+ if (this._progressTimer) {
6730
+ clearTimeout(this._progressTimer);
6731
+ this._progressTimer = null;
6732
+ }
6733
+ this._pendingProgress = null;
6734
+ }
5758
6735
  handleAssistantMessage(msg, onProgress) {
5759
6736
  const textParts = [];
6737
+ const toolCalls = [];
6738
+ const ts = nowISO();
5760
6739
  for (const block of msg.message.content) {
5761
6740
  if (block.type === "text") {
5762
6741
  textParts.push(block.text);
6742
+ } else if (block.type === "tool_use") {
6743
+ toolCalls.push(toolUseBlockToRecord(block, ts));
5763
6744
  }
5764
6745
  }
5765
6746
  const text = textParts.join("\n");
5766
- if (!text) return;
5767
- this.state.append("conversation", applyTurnEnvelope({
6747
+ if (!text && toolCalls.length === 0) return;
6748
+ const entry = {
5768
6749
  type: "assistant" /* Assistant */,
5769
6750
  uuid: generateId("a"),
5770
6751
  parentUuid: null,
5771
6752
  sessionId: this.ctx.sessionId,
5772
- timestamp: nowISO(),
6753
+ timestamp: ts,
6754
+ startedAt: ts,
5773
6755
  message: {
5774
6756
  role: "assistant",
5775
6757
  content: text,
@@ -5780,26 +6762,112 @@ controller: ${answer}`,
5780
6762
  cachedInputTokens: msg.message.usage.cache_read_input_tokens
5781
6763
  }
5782
6764
  }
5783
- }
5784
- }, this._currentTurnEnv ?? void 0));
5785
- if (onProgress) {
6765
+ },
6766
+ ...toolCalls.length > 0 && { toolCalls }
6767
+ };
6768
+ if (this._pendingToolSteps.length > 0 || toolCalls.length > 0) {
6769
+ this._pendingToolSteps.push(entry);
6770
+ } else {
6771
+ entry.endedAt = ts;
6772
+ this.appendConversationEntry(entry);
6773
+ }
6774
+ if (text && onProgress) {
5786
6775
  this._pendingProgress = text.slice(0, 200);
5787
6776
  if (this._progressTimer) clearTimeout(this._progressTimer);
5788
6777
  this._progressTimer = setTimeout(() => {
5789
- if (this._pendingProgress) {
5790
- onProgress(this._pendingProgress);
6778
+ this._progressTimer = null;
6779
+ if (this._pendingProgress === null || this.abortController.signal.aborted || this._streamEnded || this._terminalResult !== null) {
5791
6780
  this._pendingProgress = null;
6781
+ return;
5792
6782
  }
5793
- this._progressTimer = null;
6783
+ const pending = this._pendingProgress;
6784
+ this._pendingProgress = null;
6785
+ onProgress(pending);
5794
6786
  }, _ClaudeCodeWorker.PROGRESS_DEBOUNCE_MS);
5795
6787
  }
5796
6788
  }
5797
- handleResultMessage(msg) {
5798
- if (this._progressTimer) {
5799
- clearTimeout(this._progressTimer);
5800
- this._progressTimer = null;
5801
- this._pendingProgress = null;
6789
+ /**
6790
+ * SDK user messages carry the tool_result blocks for the preceding
6791
+ * assistant step's tool_use calls. Each result is merged into the buffered
6792
+ * entry that issued the matching id; completed steps are then written,
6793
+ * oldest first.
6794
+ *
6795
+ * User messages WITHOUT tool_result blocks (echoes of the task prompt or of
6796
+ * controller messages this worker itself injected) are ignored — the task
6797
+ * entry is written by run() and controller messages are audited elsewhere,
6798
+ * so writing them here would duplicate entries.
6799
+ */
6800
+ handleUserMessage(msg) {
6801
+ const content = msg.message.content;
6802
+ if (!Array.isArray(content)) return;
6803
+ const ts = nowISO();
6804
+ for (const block of content) {
6805
+ if (!block || block.type !== "tool_result") continue;
6806
+ const resultBlock = block;
6807
+ const owner = this._pendingToolSteps.find(
6808
+ (step) => step.toolCalls?.some((tc) => tc.toolCallId === resultBlock.tool_use_id)
6809
+ );
6810
+ if (!owner) {
6811
+ this.logger.debug(
6812
+ { toolUseId: resultBlock.tool_use_id },
6813
+ "tool_result with no matching pending tool_use \u2014 dropped"
6814
+ );
6815
+ continue;
6816
+ }
6817
+ const call = owner.toolCalls.find((tc) => tc.toolCallId === resultBlock.tool_use_id);
6818
+ const result = toolResultBlockToRecord(resultBlock, call.toolName, ts);
6819
+ (owner.toolResults ??= []).push(result);
6820
+ call.status = result.status;
6821
+ call.endedAt = ts;
6822
+ }
6823
+ this.flushCompletedToolSteps();
6824
+ }
6825
+ /**
6826
+ * Write buffered steps whose tool calls have ALL received results, from the
6827
+ * head of the queue (order-preserving). Steps still missing results stay
6828
+ * buffered — a later user message or the end-of-stream flush resolves them.
6829
+ */
6830
+ flushCompletedToolSteps() {
6831
+ while (this._pendingToolSteps.length > 0) {
6832
+ const head = this._pendingToolSteps[0];
6833
+ const callIds = new Set(head.toolCalls?.map((tc) => tc.toolCallId) ?? []);
6834
+ const resultIds = new Set(head.toolResults?.map((tr) => tr.toolCallId) ?? []);
6835
+ let complete = true;
6836
+ for (const id of callIds) {
6837
+ if (!resultIds.has(id)) {
6838
+ complete = false;
6839
+ break;
6840
+ }
6841
+ }
6842
+ if (!complete) break;
6843
+ this._pendingToolSteps.shift();
6844
+ head.endedAt = nowISO();
6845
+ this.appendConversationEntry(head);
6846
+ }
6847
+ }
6848
+ /**
6849
+ * End-of-stream fallback: write every still-buffered step so nothing is
6850
+ * lost. Tool calls that never received a result (abort, CLI death mid-tool)
6851
+ * are marked 'cancelled' instead of claiming to run forever.
6852
+ */
6853
+ flushAllToolSteps() {
6854
+ if (this._pendingToolSteps.length === 0) return;
6855
+ const ts = nowISO();
6856
+ for (const entry of this._pendingToolSteps) {
6857
+ const resultIds = new Set(entry.toolResults?.map((tr) => tr.toolCallId) ?? []);
6858
+ for (const tc of entry.toolCalls ?? []) {
6859
+ if (!resultIds.has(tc.toolCallId)) {
6860
+ tc.status = "cancelled";
6861
+ if (tc.endedAt === void 0) tc.endedAt = ts;
6862
+ }
6863
+ }
6864
+ entry.endedAt = ts;
6865
+ this.appendConversationEntry(entry);
5802
6866
  }
6867
+ this._pendingToolSteps = [];
6868
+ }
6869
+ handleResultMessage(msg) {
6870
+ this.clearPendingProgress();
5803
6871
  if (msg.subtype === "success") {
5804
6872
  this._terminalResult = {
5805
6873
  status: "completed",
@@ -5812,9 +6880,9 @@ controller: ${answer}`,
5812
6880
  };
5813
6881
  }
5814
6882
  if (this._messageResolve) {
5815
- const resolve11 = this._messageResolve;
6883
+ const resolve12 = this._messageResolve;
5816
6884
  this._messageResolve = null;
5817
- resolve11();
6885
+ resolve12();
5818
6886
  }
5819
6887
  const prev = this.state.get("runtimeState");
5820
6888
  this.state.set("runtimeState", {
@@ -5954,13 +7022,21 @@ function disposeWorkerTool(deps) {
5954
7022
 
5955
7023
  // src/services/tools/get-task-detail.ts
5956
7024
  import { z as z7 } from "zod";
7025
+ var OUTPUT_OVERHEAD_CHARS = 1500;
7026
+ var DEFAULT_LIMIT = 10;
7027
+ var DEFAULT_MAX_CHARS = 200;
5957
7028
  function getTaskDetailTool(deps) {
5958
7029
  return defineTool({
5959
- description: "Get detailed information about a specific task",
7030
+ description: "Get detailed information about a specific task, including its worker conversation. The conversation comes from the worker's live in-memory state while it runs, and from its on-disk transcript after the worker ended (marked [from transcript]). The conversation is paginated and truncation is always marked explicitly: offset skips N entries counted back from the newest; output is ordered oldest\u2192newest; type filtering is applied BEFORE pagination. limit (default 10 entries, counted back from the newest), maxChars (per-entry character cap, default 200) and full=true (complete text, all entries). A machine-readable meta line reports totalEntries / returnedEntries / offset / truncated.",
5960
7031
  parameters: z7.object({
5961
- taskId: z7.string().describe("Task ID")
7032
+ taskId: z7.string().describe("Task ID"),
7033
+ limit: z7.number().int().min(1).optional().describe(`Max entries to return (default ${DEFAULT_LIMIT})`),
7034
+ offset: z7.number().int().min(0).optional().describe("Skip the newest N entries (default 0)"),
7035
+ maxChars: z7.number().int().min(1).optional().describe(`Per-entry character cap (default ${DEFAULT_MAX_CHARS})`),
7036
+ full: z7.boolean().optional().describe("Return complete entry text and all entries (ignores limit/maxChars)")
5962
7037
  }),
5963
- execute: safeExecute("get_task_detail", async ({ taskId }) => {
7038
+ // Full reports can be far longer than the default tool-output cap.
7039
+ execute: safeExecute("get_task_detail", async ({ taskId, limit, offset, maxChars, full }) => {
5964
7040
  const task = deps.getTask(taskId);
5965
7041
  if (!task) return `Task ${taskId} not found`;
5966
7042
  const lines = [
@@ -5975,30 +7051,313 @@ function getTaskDetailTool(deps) {
5975
7051
  if (task.result) lines.push(`Result: ${task.result}`);
5976
7052
  if (task.error) lines.push(`Error: ${task.error}`);
5977
7053
  if (task.workerSessionId) lines.push(`Worker: ${task.workerSessionId}`);
7054
+ const isFull = full === true;
7055
+ const requestedLimit = limit ?? DEFAULT_LIMIT;
7056
+ let entries = [];
7057
+ let source = "none";
7058
+ let incompleteTail = false;
7059
+ let readWindowTruncated = false;
5978
7060
  if (deps.getWorkerConversation) {
5979
- const entries = deps.getWorkerConversation(taskId);
5980
- if (entries.length > 0) {
5981
- lines.push(`
5982
- Conversation (${entries.length} entries):`);
5983
- for (const entry of entries.slice(-10)) {
5984
- lines.push(` [${entry.type}] ${entry.message?.content ?? entry.content ?? ""}`.slice(0, 200));
7061
+ entries = deps.getWorkerConversation(taskId);
7062
+ if (entries.length > 0) source = "memory";
7063
+ }
7064
+ if (entries.length === 0 && deps.readTranscriptWindow) {
7065
+ const diskIds = [task.workerTranscriptId, task.workerSessionId].filter((id) => typeof id === "string");
7066
+ for (const id of [...new Set(diskIds)]) {
7067
+ const win2 = deps.readTranscriptWindow(id, {
7068
+ need: isFull ? void 0 : (offset ?? 0) + requestedLimit
7069
+ });
7070
+ if (win2 && win2.entries.length > 0) {
7071
+ entries = win2.entries;
7072
+ source = "transcript";
7073
+ incompleteTail = win2.incompleteTail;
7074
+ readWindowTruncated = win2.windowTruncated;
7075
+ break;
5985
7076
  }
5986
7077
  }
5987
7078
  }
7079
+ if (entries.length === 0) return lines.join("\n");
7080
+ const perEntryCap = isFull ? null : maxChars ?? DEFAULT_MAX_CHARS;
7081
+ const requested = selectWindow(entries, offset ?? 0, isFull ? entries.length : requestedLimit);
7082
+ const rendered = renderEntriesFitting(
7083
+ requested.entries,
7084
+ requested.startIndex,
7085
+ { maxChars: perEntryCap, includeToolDetail: isFull },
7086
+ MAX_TRANSCRIPT_RESULT_CHARS - OUTPUT_OVERHEAD_CHARS
7087
+ );
7088
+ const win = shrinkWindow(requested, rendered.dropped);
7089
+ const body = rendered.lines;
7090
+ const base = buildWindowMeta(win, { maxChars: perEntryCap, full: isFull, shortenedEntries: rendered.shortened });
7091
+ const meta = {
7092
+ ...base,
7093
+ source,
7094
+ incompleteTail,
7095
+ readWindowTruncated,
7096
+ windowTruncated: base.windowTruncated || readWindowTruncated,
7097
+ truncated: base.truncated || readWindowTruncated
7098
+ };
7099
+ lines.push("");
7100
+ let header = buildTranscriptHeader(meta);
7101
+ if (readWindowTruncated) {
7102
+ header = `Conversation (${meta.totalEntries} entries in read window, showing ${meta.startIndex}-${meta.endIndex}; older entries exist beyond the read-window cap)`;
7103
+ }
7104
+ lines.push(`${header}${source === "transcript" ? " [from transcript]" : ""}`);
7105
+ lines.push(...body);
7106
+ if (incompleteTail) {
7107
+ lines.push("[note: last entry is incomplete \u2014 the transcript is still being written]");
7108
+ }
7109
+ if (readWindowTruncated) {
7110
+ lines.push("[note: transcript read window hit its byte cap \u2014 older entries were not read; page back with offset/limit]");
7111
+ }
7112
+ const notice = buildTruncationNotice(meta);
7113
+ if (notice) lines.push(notice);
7114
+ lines.push(`[conversation-meta] ${JSON.stringify(meta)}`);
5988
7115
  return lines.join("\n");
5989
- })
7116
+ }, { maxResultChars: MAX_TRANSCRIPT_RESULT_CHARS })
5990
7117
  });
5991
7118
  }
5992
7119
 
5993
- // src/services/tools/update-task.ts
7120
+ // src/services/tools/read-worker-conversation.ts
5994
7121
  import { z as z8 } from "zod";
7122
+ var OUTPUT_OVERHEAD_CHARS2 = 1500;
7123
+ var DEFAULT_LIMIT2 = 20;
7124
+ var DEFAULT_MAX_CHARS2 = 200;
7125
+ var FILTER_TYPES = ["user", "assistant", "system", "signal", "tool"];
7126
+ function typeMatches(entry, filter) {
7127
+ if (filter === "tool") {
7128
+ const calls = entry.toolCalls;
7129
+ return Array.isArray(calls) && calls.length > 0;
7130
+ }
7131
+ return entry.type === filter;
7132
+ }
7133
+ function readWorkerConversationTool(deps) {
7134
+ return defineTool({
7135
+ description: `Read a worker's conversation transcript. Layered source: while the engine still tracks the worker (running or idle), its live in-memory conversation is returned (meta source="memory"); once the worker finished/failed/was cancelled or disposed, the append-only on-disk file <sessionDir>/workers/<transcript id>/conversation.jsonl is read instead (meta source="disk") \u2014 so it works for running AND completed/failed/cancelled workers. Pagination: offset skips N entries counted back from the newest; output is ordered oldest\u2192newest; type filtering is applied BEFORE pagination. limit (default 20, max entries), maxChars (per-entry cap, default 200), full=true (complete text, all entries \u2014 still bounded by a 2 MB read window), type (filter: user/assistant/system/signal/tool). Truncation is always marked explicitly and reported as totalEntries / returnedEntries / offset / truncated, plus incompleteTail (last entry still being written) and readWindowTruncated (older entries exist beyond the read-window cap \u2014 page back with offset).`,
7136
+ parameters: z8.object({
7137
+ workerId: z8.string().describe("Worker ID (e.g. w-1793587065421-6h37)"),
7138
+ limit: z8.number().int().min(1).optional().describe(`Max entries to return (default ${DEFAULT_LIMIT2})`),
7139
+ offset: z8.number().int().min(0).optional().describe("Skip the newest N entries (default 0)"),
7140
+ maxChars: z8.number().int().min(1).optional().describe(`Per-entry character cap (default ${DEFAULT_MAX_CHARS2})`),
7141
+ full: z8.boolean().optional().describe("Return complete entry text and all entries (ignores limit/maxChars)"),
7142
+ type: z8.enum(FILTER_TYPES).optional().describe('Only entries of this type; "tool" = assistant entries that made tool calls')
7143
+ }),
7144
+ execute: safeExecute("read_worker_conversation", async ({ workerId, limit, offset, maxChars, full, type }) => {
7145
+ const now = deps.now?.() ?? Date.now();
7146
+ if (!isValidWorkerId(workerId)) {
7147
+ return `Invalid worker id "${workerId}" \u2014 worker ids are alphanumeric with dashes/underscores only.`;
7148
+ }
7149
+ const task = deps.listTasks?.().find((t) => t.workerSessionId === workerId);
7150
+ const isActive = deps.listWorkers?.().some((w) => w.workerId === workerId) ?? false;
7151
+ const liveTranscriptId = deps.getWorkerTranscriptId?.(workerId);
7152
+ const isFull = full === true;
7153
+ const requestedLimit = limit ?? DEFAULT_LIMIT2;
7154
+ const need = isFull ? void 0 : (offset ?? 0) + requestedLimit;
7155
+ const matches = type === void 0 ? void 0 : (e) => typeMatches(e, type);
7156
+ const memory = deps.getWorkerConversationByWorkerId?.(workerId);
7157
+ if (memory !== void 0 && memory.length > 0) {
7158
+ return render2({ source: "memory", entries: memory });
7159
+ }
7160
+ const candidates = [];
7161
+ for (const id of [task?.workerTranscriptId, liveTranscriptId, workerId]) {
7162
+ if (typeof id === "string" && !candidates.includes(id)) candidates.push(id);
7163
+ }
7164
+ let disk = null;
7165
+ for (const id of candidates) {
7166
+ const win = deps.readTranscriptWindow(id, { need, matches });
7167
+ if (win !== null) {
7168
+ disk = { id, win };
7169
+ break;
7170
+ }
7171
+ }
7172
+ if (disk !== null) {
7173
+ return render2({
7174
+ source: "disk",
7175
+ entries: disk.win.entries,
7176
+ transcriptId: disk.id,
7177
+ incompleteTail: disk.win.incompleteTail,
7178
+ readWindowTruncated: disk.win.windowTruncated
7179
+ });
7180
+ }
7181
+ const known = isActive || task !== void 0 || memory !== void 0;
7182
+ if (!known) {
7183
+ return `Unknown worker ${workerId}: no active worker and no task references it.`;
7184
+ }
7185
+ if (isActive) {
7186
+ return `Worker ${workerId} is running but has not written any transcript entries yet.`;
7187
+ }
7188
+ const resolved = task?.workerTranscriptId ?? liveTranscriptId;
7189
+ if (resolved !== void 0) {
7190
+ return `Worker ${workerId} exists but its transcript directory is missing on disk (expected ${workerTranscriptPath(deps.sessionDir, resolved)}).`;
7191
+ }
7192
+ return `Worker ${workerId} exists but its transcript cannot be located on disk (legacy session id layout?).`;
7193
+ function render2(src) {
7194
+ const { source, entries } = src;
7195
+ const incompleteTail = src.incompleteTail ?? false;
7196
+ const readWindowTruncated = src.readWindowTruncated ?? false;
7197
+ if (entries.length === 0) {
7198
+ const emptyMsg = `Transcript for worker ${workerId} exists but contains no complete entries yet.`;
7199
+ return incompleteTail ? `${emptyMsg}
7200
+ [note: last entry is incomplete \u2014 the transcript is still being written]` : emptyMsg;
7201
+ }
7202
+ const filter = type;
7203
+ const population = filter === void 0 ? entries : entries.filter((e) => typeMatches(e, filter));
7204
+ const reqLimit = isFull ? population.length : requestedLimit;
7205
+ const perEntryCap = isFull ? null : maxChars ?? DEFAULT_MAX_CHARS2;
7206
+ const requested = selectWindow(population, offset ?? 0, reqLimit);
7207
+ const rendered = renderEntriesFitting(
7208
+ requested.entries,
7209
+ requested.startIndex,
7210
+ { maxChars: perEntryCap, includeToolDetail: isFull },
7211
+ MAX_TRANSCRIPT_RESULT_CHARS - OUTPUT_OVERHEAD_CHARS2
7212
+ );
7213
+ const win = shrinkWindow(requested, rendered.dropped);
7214
+ const body = rendered.lines;
7215
+ const base = buildWindowMeta(win, {
7216
+ maxChars: perEntryCap,
7217
+ full: isFull,
7218
+ shortenedEntries: rendered.shortened
7219
+ });
7220
+ const meta = {
7221
+ workerId,
7222
+ source,
7223
+ ...src.transcriptId !== void 0 && { transcriptId: src.transcriptId },
7224
+ ...base,
7225
+ incompleteTail,
7226
+ readWindowTruncated,
7227
+ windowTruncated: base.windowTruncated || readWindowTruncated,
7228
+ truncated: base.truncated || readWindowTruncated,
7229
+ ...filter !== void 0 && { filter, totalEntriesBeforeFilter: entries.length }
7230
+ };
7231
+ const summary = summarizeTranscript(entries);
7232
+ const headLine = source === "disk" ? `Worker ${workerId} transcript (source=disk, dir id ${src.transcriptId}): ` + workerTranscriptPath(deps.sessionDir, src.transcriptId ?? workerId) : `Worker ${workerId} conversation (source=memory \u2014 live in-memory state of the tracked worker)`;
7233
+ const lines = [
7234
+ headLine,
7235
+ `Last activity: ${formatLastActivity(summary.lastActivityAt, now)}; currentTool: ${summary.currentTool ?? "-"}; terminal marker: ${summary.terminalSubtype ?? "-"}`
7236
+ ];
7237
+ let header = buildTranscriptHeader(meta);
7238
+ if (readWindowTruncated) {
7239
+ header = `Conversation (${meta.totalEntries} entries in read window, showing ${meta.startIndex}-${meta.endIndex}; older entries exist beyond the read-window cap):`;
7240
+ }
7241
+ lines.push(header + (filter !== void 0 ? ` [type=${filter}; ${entries.length} entries in transcript]` : ""));
7242
+ lines.push(...body);
7243
+ if (incompleteTail) {
7244
+ lines.push("[note: last entry is incomplete \u2014 the transcript is still being written]");
7245
+ }
7246
+ if (readWindowTruncated) {
7247
+ lines.push("[note: transcript read window hit its byte cap \u2014 older entries were not read; page back with offset/limit]");
7248
+ }
7249
+ const notice = buildTruncationNotice(meta);
7250
+ if (notice) lines.push(notice);
7251
+ lines.push(`[transcript-meta] ${JSON.stringify(meta)}`);
7252
+ return lines.join("\n");
7253
+ }
7254
+ }, { maxResultChars: MAX_TRANSCRIPT_RESULT_CHARS })
7255
+ });
7256
+ }
7257
+
7258
+ // src/services/tools/get-worker-status.ts
7259
+ import { z as z9 } from "zod";
7260
+ var SETTLED_TASK_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
7261
+ function taskStatusToWorkerStatus(status) {
7262
+ if (status === "running") return "running";
7263
+ if (SETTLED_TASK_STATUSES.has(status)) return status;
7264
+ return "unknown";
7265
+ }
7266
+ function buildSnapshot(workerId, deps, now) {
7267
+ const active = deps.listWorkers().find((w) => w.workerId === workerId);
7268
+ const tasks = deps.listTasks();
7269
+ const task = active ? tasks.find((t) => t.id === active.taskId) : tasks.find((t) => t.workerSessionId === workerId);
7270
+ const entries = deps.readTranscript(workerId);
7271
+ if (!active && !task && entries === null) return null;
7272
+ const summary = summarizeTranscript(entries ?? []);
7273
+ const live = deps.getLiveStatus?.(workerId);
7274
+ const workerType = active?.workerType ?? task?.workerType ?? "unknown";
7275
+ const settled = task ? SETTLED_TASK_STATUSES.has(task.status) : false;
7276
+ const terminalTracked = workerType !== "puppet";
7277
+ const inferredTerminal = summary.hasTerminalEntry || settled;
7278
+ let runningForMs = null;
7279
+ if (active) {
7280
+ runningForMs = Math.max(0, now - active.startedAt);
7281
+ } else if (task?.startedAt) {
7282
+ runningForMs = Math.max(0, (task.completedAt ?? now) - task.startedAt);
7283
+ }
7284
+ return {
7285
+ workerId,
7286
+ type: workerType,
7287
+ taskId: active?.taskId ?? task?.id ?? null,
7288
+ status: active?.status ?? (task ? taskStatusToWorkerStatus(task.status) : "unknown"),
7289
+ runningForMs,
7290
+ lastActivityAt: summary.lastActivityAt,
7291
+ lastActivityAgoMs: summary.lastActivityAt === null ? null : Math.max(0, now - summary.lastActivityAt),
7292
+ currentTool: summary.currentTool,
7293
+ streamEnded: live?.streamEnded ?? (inferredTerminal ? true : terminalTracked ? false : null),
7294
+ resultReceived: live?.resultReceived ?? (inferredTerminal ? true : terminalTracked ? false : null),
7295
+ idleSince: active?.status === "idle" ? active.idleSinceMs ?? null : null,
7296
+ live: active !== void 0,
7297
+ lastEntryType: summary.lastEntryType,
7298
+ entries: summary.totalEntries
7299
+ };
7300
+ }
7301
+ function boolLabel(v) {
7302
+ return v === null ? "unknown" : String(v);
7303
+ }
7304
+ function formatSnapshot(s, now) {
7305
+ return [
7306
+ `Worker ${s.workerId} (${s.type})`,
7307
+ ` task: ${s.taskId ?? "-"}`,
7308
+ ` status: ${s.status}${s.live ? " (tracked)" : " (no longer tracked)"}`,
7309
+ ` runningForMs: ${s.runningForMs === null ? "unknown" : `${s.runningForMs} (${formatDuration(s.runningForMs)})`}`,
7310
+ ` lastActivityAt: ${formatLastActivity(s.lastActivityAt, now)}`,
7311
+ ` currentTool: ${s.currentTool ?? "-"}`,
7312
+ ` streamEnded: ${boolLabel(s.streamEnded)}`,
7313
+ ` resultReceived: ${boolLabel(s.resultReceived)}`,
7314
+ ` idleSince: ${s.idleSince === null ? "-" : formatLastActivity(s.idleSince, now)}`,
7315
+ ` entries: ${s.entries} (last: ${s.lastEntryType ?? "-"})`
7316
+ ];
7317
+ }
7318
+ function formatSnapshotLine(s) {
7319
+ const activity = s.lastActivityAgoMs === null ? "none" : `${formatDuration(s.lastActivityAgoMs)} ago`;
7320
+ return ` [${s.status}] ${s.workerId} (${s.type}) task=${s.taskId ?? "-"} runningFor=${s.runningForMs === null ? "unknown" : formatDuration(s.runningForMs)} lastActivity=${activity} tool=${s.currentTool ?? "-"} streamEnded=${boolLabel(s.streamEnded)} resultReceived=${boolLabel(s.resultReceived)}`;
7321
+ }
7322
+ function getWorkerStatusTool(deps) {
7323
+ return defineTool({
7324
+ description: 'Get the liveness of one worker (workerId given) or of all current workers (omitted). Reports status, runningForMs, lastActivityAt (epoch ms of the newest transcript entry; lastActivityAgoMs is how long ago that was), currentTool, streamEnded, resultReceived and idleSince \u2014 use it to tell "still working" from "stuck / already finished and just not released" before deciding to wait, chat, or cancel. Unknown worker ids return a not-found message instead of throwing. A machine-readable [worker-status] line carries the same data as JSON.',
7325
+ parameters: z9.object({
7326
+ workerId: z9.string().optional().describe("Worker ID; omit to list all current workers")
7327
+ }),
7328
+ execute: safeExecute("get_worker_status", async ({ workerId }) => {
7329
+ const now = deps.now?.() ?? Date.now();
7330
+ if (workerId !== void 0 && !isValidWorkerId(workerId)) {
7331
+ return `Invalid worker id "${workerId}" \u2014 worker ids are alphanumeric with dashes/underscores only.`;
7332
+ }
7333
+ if (workerId === void 0) {
7334
+ const workers = deps.listWorkers();
7335
+ if (workers.length === 0) {
7336
+ return "No workers are running or idle.\n[worker-status] []";
7337
+ }
7338
+ const snapshots = workers.map((w) => buildSnapshot(w.workerId, deps, now)).filter((s) => s !== null);
7339
+ const lines = [`Workers (${snapshots.length}):`, ...snapshots.map(formatSnapshotLine)];
7340
+ lines.push(`[worker-status] ${JSON.stringify(snapshots)}`);
7341
+ return lines.join("\n");
7342
+ }
7343
+ const snapshot = buildSnapshot(workerId, deps, now);
7344
+ if (!snapshot) {
7345
+ return `Worker ${workerId} not found (not tracked, no task references it, no transcript on disk).`;
7346
+ }
7347
+ return [...formatSnapshot(snapshot, now), `[worker-status] ${JSON.stringify(snapshot)}`].join("\n");
7348
+ }, { maxResultChars: MAX_TRANSCRIPT_RESULT_CHARS })
7349
+ });
7350
+ }
7351
+
7352
+ // src/services/tools/update-task.ts
7353
+ import { z as z10 } from "zod";
5995
7354
  function updateTaskTool(deps) {
5996
7355
  return defineTool({
5997
7356
  description: "Update a pending task description or priority",
5998
- parameters: z8.object({
5999
- taskId: z8.string().describe("Task ID to update"),
6000
- description: z8.string().optional().describe("New task description"),
6001
- priority: z8.enum(["normal", "high"]).optional().describe("New priority")
7357
+ parameters: z10.object({
7358
+ taskId: z10.string().describe("Task ID to update"),
7359
+ description: z10.string().optional().describe("New task description"),
7360
+ priority: z10.enum(["normal", "high"]).optional().describe("New priority")
6002
7361
  }),
6003
7362
  execute: safeExecute("update_task", async ({ taskId, description, priority }) => {
6004
7363
  if (!description && !priority) {
@@ -6012,14 +7371,14 @@ function updateTaskTool(deps) {
6012
7371
  }
6013
7372
 
6014
7373
  // src/services/tools/query-signals.ts
6015
- import { z as z9 } from "zod";
7374
+ import { z as z11 } from "zod";
6016
7375
  function querySignalsTool(deps) {
6017
7376
  return defineTool({
6018
7377
  description: "Query signal history by status, source, or type",
6019
- parameters: z9.object({
6020
- status: z9.enum(["unread", "read", "discarded"]).optional().describe("Filter by status"),
6021
- source: z9.enum(["user", "worker", "system"]).optional().describe("Filter by source"),
6022
- type: z9.enum(["input", "completed", "failed", "progress", "cancelled"]).optional().describe("Filter by type")
7378
+ parameters: z11.object({
7379
+ status: z11.enum(["unread", "read", "discarded"]).optional().describe("Filter by status"),
7380
+ source: z11.enum(["user", "worker", "system"]).optional().describe("Filter by source"),
7381
+ type: z11.enum(["input", "completed", "failed", "progress", "cancelled"]).optional().describe("Filter by type")
6023
7382
  }),
6024
7383
  execute: safeExecute("query_signals", async (filter) => {
6025
7384
  const signals = deps.querySignals(filter);
@@ -6032,11 +7391,11 @@ function querySignalsTool(deps) {
6032
7391
  }
6033
7392
 
6034
7393
  // src/services/tools/skip-reply.ts
6035
- import { z as z10 } from "zod";
7394
+ import { z as z12 } from "zod";
6036
7395
  function skipReplyTool() {
6037
7396
  return defineTool({
6038
7397
  description: "Acknowledge a signal without responding to the user. IMPORTANT: this tool terminates the turn immediately. You MUST NOT output any text in the same response \u2014 your response must contain ONLY this tool call. Any co-emitted text will leak to the user as a broken message.",
6039
- parameters: z10.object({}),
7398
+ parameters: z12.object({}),
6040
7399
  execute: safeExecute("skip_reply", async () => {
6041
7400
  return "(skipped)";
6042
7401
  })
@@ -6044,13 +7403,13 @@ function skipReplyTool() {
6044
7403
  }
6045
7404
 
6046
7405
  // src/services/tools/bash.ts
6047
- import { z as z11 } from "zod";
7406
+ import { z as z13 } from "zod";
6048
7407
  import { execSync as execSync2 } from "child_process";
6049
7408
  function bashTool(deps) {
6050
7409
  return defineTool({
6051
7410
  description: "Execute a shell command and return its output",
6052
- parameters: z11.object({
6053
- command: z11.string().describe("Shell command to execute")
7411
+ parameters: z13.object({
7412
+ command: z13.string().describe("Shell command to execute")
6054
7413
  }),
6055
7414
  execute: safeExecute("bash", async ({ command }) => {
6056
7415
  try {
@@ -6072,22 +7431,22 @@ ${detail}`;
6072
7431
  }
6073
7432
 
6074
7433
  // src/services/tools/read-file.ts
6075
- import { z as z12 } from "zod";
6076
- import { readFileSync as readFileSync6 } from "fs";
6077
- import { resolve as resolve2 } from "path";
7434
+ import { z as z14 } from "zod";
7435
+ import { readFileSync as readFileSync7 } from "fs";
7436
+ import { resolve as resolve3 } from "path";
6078
7437
  var DEFAULT_MAX_LINES = 500;
6079
7438
  function readFileTool(deps = {}) {
6080
7439
  const workspace = deps.cwd ?? process.cwd();
6081
7440
  return defineTool({
6082
7441
  description: `Read file content (with line numbers). Returns at most ${DEFAULT_MAX_LINES} lines by default. Use offset (1-based start line) and limit to read specific sections of large files.`,
6083
- parameters: z12.object({
6084
- path: z12.string().describe("File path (relative to workspace or absolute)"),
6085
- offset: z12.number().int().min(1).optional().describe("Start line number (1-based)"),
6086
- limit: z12.number().int().min(1).optional().describe("Number of lines to read")
7442
+ parameters: z14.object({
7443
+ path: z14.string().describe("File path (relative to workspace or absolute)"),
7444
+ offset: z14.number().int().min(1).optional().describe("Start line number (1-based)"),
7445
+ limit: z14.number().int().min(1).optional().describe("Number of lines to read")
6087
7446
  }),
6088
7447
  execute: safeExecute("read_file", async ({ path: path7, offset, limit }) => {
6089
- const resolved = resolve2(workspace, path7);
6090
- const content = readFileSync6(resolved, "utf-8");
7448
+ const resolved = resolve3(workspace, path7);
7449
+ const content = readFileSync7(resolved, "utf-8");
6091
7450
  const lines = content.split("\n");
6092
7451
  const totalLines = lines.length;
6093
7452
  const start = offset ? offset - 1 : 0;
@@ -6106,23 +7465,23 @@ function readFileTool(deps = {}) {
6106
7465
  }
6107
7466
 
6108
7467
  // src/services/tools/write-file.ts
6109
- import { z as z13 } from "zod";
6110
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, existsSync as existsSync5 } from "fs";
6111
- import { dirname as dirname4, resolve as resolve3 } from "path";
7468
+ import { z as z15 } from "zod";
7469
+ import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, existsSync as existsSync7 } from "fs";
7470
+ import { dirname as dirname5, resolve as resolve4 } from "path";
6112
7471
  function writeFileTool(deps = {}) {
6113
7472
  const workspace = deps.cwd ?? process.cwd();
6114
7473
  return defineTool({
6115
7474
  description: "Create a new file with the given content. Creates parent directories as needed. Use edit_file to modify existing files.",
6116
- parameters: z13.object({
6117
- path: z13.string().describe("File path (relative to workspace or absolute)"),
6118
- content: z13.string().describe("File content")
7475
+ parameters: z15.object({
7476
+ path: z15.string().describe("File path (relative to workspace or absolute)"),
7477
+ content: z15.string().describe("File content")
6119
7478
  }),
6120
7479
  execute: safeExecute("write_file", async ({ path: path7, content }) => {
6121
- const resolved = resolve3(workspace, path7);
6122
- if (existsSync5(resolved)) {
7480
+ const resolved = resolve4(workspace, path7);
7481
+ if (existsSync7(resolved)) {
6123
7482
  return `[ERROR] write_file: File already exists: ${path7}. Use edit_file to modify existing files.`;
6124
7483
  }
6125
- mkdirSync4(dirname4(resolved), { recursive: true });
7484
+ mkdirSync4(dirname5(resolved), { recursive: true });
6126
7485
  writeFileSync3(resolved, content, "utf-8");
6127
7486
  return `Written ${content.length} chars to ${resolved}`;
6128
7487
  })
@@ -6130,23 +7489,23 @@ function writeFileTool(deps = {}) {
6130
7489
  }
6131
7490
 
6132
7491
  // src/services/tools/edit-file.ts
6133
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
6134
- import { resolve as resolve4 } from "path";
6135
- import { z as z14 } from "zod";
7492
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
7493
+ import { resolve as resolve5 } from "path";
7494
+ import { z as z16 } from "zod";
6136
7495
  function editFileTool(deps) {
6137
7496
  const workspace = deps.cwd ?? process.cwd();
6138
7497
  return defineTool({
6139
7498
  description: "Edit a file by replacing a unique string. old_string must appear exactly once in the file \u2014 if it matches multiple times, expand the surrounding context to make it unique.",
6140
- parameters: z14.object({
6141
- path: z14.string().describe("File path (relative to workspace or absolute)"),
6142
- old_string: z14.string().describe("Exact text to find (must be unique in file)"),
6143
- new_string: z14.string().describe("Replacement text")
7499
+ parameters: z16.object({
7500
+ path: z16.string().describe("File path (relative to workspace or absolute)"),
7501
+ old_string: z16.string().describe("Exact text to find (must be unique in file)"),
7502
+ new_string: z16.string().describe("Replacement text")
6144
7503
  }),
6145
7504
  execute: safeExecute("edit_file", async ({ path: path7, old_string, new_string }) => {
6146
- const resolved = resolve4(workspace, path7);
7505
+ const resolved = resolve5(workspace, path7);
6147
7506
  let content;
6148
7507
  try {
6149
- content = readFileSync7(resolved, "utf-8");
7508
+ content = readFileSync8(resolved, "utf-8");
6150
7509
  } catch {
6151
7510
  return `[ERROR] edit_file: File not found: ${path7}`;
6152
7511
  }
@@ -6164,18 +7523,18 @@ function editFileTool(deps) {
6164
7523
 
6165
7524
  // src/services/tools/search-files.ts
6166
7525
  import fg from "fast-glob";
6167
- import { resolve as resolve5 } from "path";
6168
- import { z as z15 } from "zod";
7526
+ import { resolve as resolve6 } from "path";
7527
+ import { z as z17 } from "zod";
6169
7528
  function searchFilesTool(deps) {
6170
7529
  const workspace = deps.cwd ?? process.cwd();
6171
7530
  return defineTool({
6172
7531
  description: "Search for files matching a glob pattern. Returns matching file paths relative to workspace.",
6173
- parameters: z15.object({
6174
- pattern: z15.string().describe('Glob pattern (e.g. "**/*.ts", "src/**/*.test.ts")'),
6175
- path: z15.string().optional().describe("Base directory for search (default: workspace root)")
7532
+ parameters: z17.object({
7533
+ pattern: z17.string().describe('Glob pattern (e.g. "**/*.ts", "src/**/*.test.ts")'),
7534
+ path: z17.string().optional().describe("Base directory for search (default: workspace root)")
6176
7535
  }),
6177
7536
  execute: safeExecute("search_files", async ({ pattern, path: path7 }) => {
6178
- const basePath = path7 ? resolve5(workspace, path7) : workspace;
7537
+ const basePath = path7 ? resolve6(workspace, path7) : workspace;
6179
7538
  const entries = await fg(pattern, {
6180
7539
  cwd: basePath,
6181
7540
  onlyFiles: true,
@@ -6189,8 +7548,8 @@ function searchFilesTool(deps) {
6189
7548
 
6190
7549
  // src/services/tools/search-content.ts
6191
7550
  import { execSync as execSync3 } from "child_process";
6192
- import { resolve as resolve6 } from "path";
6193
- import { z as z16 } from "zod";
7551
+ import { resolve as resolve7 } from "path";
7552
+ import { z as z18 } from "zod";
6194
7553
  function shellQuote(s) {
6195
7554
  return `'${s.replace(/'/g, "'\\''")}'`;
6196
7555
  }
@@ -6198,13 +7557,13 @@ function searchContentTool(deps) {
6198
7557
  const workspace = deps.cwd ?? process.cwd();
6199
7558
  return defineTool({
6200
7559
  description: "Search file contents for a text pattern. Returns matching lines in file:line:content format.",
6201
- parameters: z16.object({
6202
- query: z16.string().describe("Search text or regex pattern"),
6203
- path: z16.string().optional().describe("Directory to search in (default: workspace root)"),
6204
- include: z16.string().optional().describe('File pattern to include (e.g. "*.ts", "*.py")')
7560
+ parameters: z18.object({
7561
+ query: z18.string().describe("Search text or regex pattern"),
7562
+ path: z18.string().optional().describe("Directory to search in (default: workspace root)"),
7563
+ include: z18.string().optional().describe('File pattern to include (e.g. "*.ts", "*.py")')
6205
7564
  }),
6206
7565
  execute: safeExecute("search_content", async ({ query: query2, path: path7, include }) => {
6207
- const searchDir = path7 ? resolve6(workspace, path7) : workspace;
7566
+ const searchDir = path7 ? resolve7(workspace, path7) : workspace;
6208
7567
  const cmd = `grep -rn${include ? ` --include=${shellQuote(include)}` : ""} -- ${shellQuote(query2)} ${shellQuote(searchDir)}`;
6209
7568
  try {
6210
7569
  const output = execSync3(cmd, {
@@ -6223,13 +7582,13 @@ ${err.stderr?.toString() || ""}`;
6223
7582
  }
6224
7583
 
6225
7584
  // src/services/tools/web-search.ts
6226
- import { z as z17 } from "zod";
7585
+ import { z as z19 } from "zod";
6227
7586
  function webSearchTool(deps) {
6228
7587
  const apiKey = deps.tavilyApiKey;
6229
7588
  return defineTool({
6230
7589
  description: "Search the web using Tavily. Returns top results with title, URL, and content snippet.",
6231
- parameters: z17.object({
6232
- query: z17.string().describe("Search query")
7590
+ parameters: z19.object({
7591
+ query: z19.string().describe("Search query")
6233
7592
  }),
6234
7593
  execute: safeExecute("web_search", async ({ query: query2 }) => {
6235
7594
  if (!apiKey) {
@@ -6260,11 +7619,11 @@ function webSearchTool(deps) {
6260
7619
  }
6261
7620
 
6262
7621
  // src/services/tools/notebook.ts
6263
- import { z as z18 } from "zod";
7622
+ import { z as z20 } from "zod";
6264
7623
  function readNotebookTool(deps) {
6265
7624
  return defineTool({
6266
7625
  description: "Read the current notebook content. The notebook is a private scratchpad that persists across context compaction \u2014 use it to store plans, notes, and working context.",
6267
- parameters: z18.object({}),
7626
+ parameters: z20.object({}),
6268
7627
  execute: safeExecute("read_notebook", async () => {
6269
7628
  const content = deps.getNotebook();
6270
7629
  return content || "(notebook is empty)";
@@ -6274,9 +7633,9 @@ function readNotebookTool(deps) {
6274
7633
  function updateNotebookTool(deps) {
6275
7634
  return defineTool({
6276
7635
  description: 'Update the notebook content. Use "replace" to overwrite the entire notebook, or "append" to add to the end. The notebook persists across context compaction.',
6277
- parameters: z18.object({
6278
- content: z18.string().describe("Content to write"),
6279
- mode: z18.enum(["replace", "append"]).optional().describe('Write mode: "replace" overwrites, "append" adds to the end (default: replace)')
7636
+ parameters: z20.object({
7637
+ content: z20.string().describe("Content to write"),
7638
+ mode: z20.enum(["replace", "append"]).optional().describe('Write mode: "replace" overwrites, "append" adds to the end (default: replace)')
6280
7639
  }),
6281
7640
  execute: safeExecute("update_notebook", async ({ content, mode }) => {
6282
7641
  deps.setNotebook(content, mode ?? "replace");
@@ -6286,15 +7645,16 @@ function updateNotebookTool(deps) {
6286
7645
  }
6287
7646
 
6288
7647
  // src/services/tools/inquire.ts
6289
- import { z as z19 } from "zod";
7648
+ import { z as z21 } from "zod";
6290
7649
  function inquireTool(deps) {
6291
7650
  return defineTool({
6292
- description: "Ask the user a structured question and wait for their response. Use this to clarify ambiguous requests, confirm approach, or let the user choose between options. The tool blocks until the user answers.",
6293
- parameters: z19.object({
6294
- message: z19.string().describe("Question to ask the user"),
6295
- type: z19.enum(["confirm", "select", "text"]).describe('Question type: "confirm" for yes/no, "select" for choosing from options, "text" for free-form input'),
6296
- options: z19.array(z19.string()).optional().describe('Available choices (required for "select" type)'),
6297
- default: z19.string().optional().describe("Default value or pre-selected option")
7651
+ description: 'Ask the user a structured question and wait for their response. Use this to clarify ambiguous requests, confirm approach, or let the user choose between options. The tool blocks until the user answers. For "select" questions an "Other" choice is appended automatically so the user can always answer outside the preset options \u2014 do not add your own catch-all option. Answers typed via "Other" come back prefixed with "[Other] ".',
7652
+ parameters: z21.object({
7653
+ message: z21.string().describe("Question to ask the user"),
7654
+ type: z21.enum(["confirm", "select", "text"]).describe('Question type: "confirm" for yes/no, "select" for choosing from options, "text" for free-form input'),
7655
+ options: z21.array(z21.string()).optional().describe('Available choices (required for "select" type). Provide 2-5 clear preset options \u2014 an "Other" choice for free-form input is appended automatically'),
7656
+ default: z21.string().optional().describe("Default value or pre-selected option"),
7657
+ allowOther: z21.boolean().optional().describe('"select" only: append an "Other" choice letting the user type a custom answer (default true). Set false to restrict answers to the preset options')
6298
7658
  }),
6299
7659
  execute: safeExecute("inquire", async (args) => {
6300
7660
  return deps.inquire(args);
@@ -6303,12 +7663,12 @@ function inquireTool(deps) {
6303
7663
  }
6304
7664
 
6305
7665
  // src/services/tools/done.ts
6306
- import { z as z20 } from "zod";
7666
+ import { z as z22 } from "zod";
6307
7667
  function doneTool(deps) {
6308
7668
  return defineTool({
6309
7669
  description: "Report task completion. Worker stops executing after this call.",
6310
- parameters: z20.object({
6311
- summary: z20.string().describe("Task completion summary")
7670
+ parameters: z22.object({
7671
+ summary: z22.string().describe("Task completion summary")
6312
7672
  }),
6313
7673
  execute: safeExecute("done", async ({ summary }) => {
6314
7674
  deps.onComplete(summary);
@@ -6318,12 +7678,12 @@ function doneTool(deps) {
6318
7678
  }
6319
7679
 
6320
7680
  // src/services/tools/fail.ts
6321
- import { z as z21 } from "zod";
7681
+ import { z as z23 } from "zod";
6322
7682
  function failTool(deps) {
6323
7683
  return defineTool({
6324
7684
  description: "Report task failure. Worker stops executing after this call.",
6325
- parameters: z21.object({
6326
- error: z21.string().describe("Error description")
7685
+ parameters: z23.object({
7686
+ error: z23.string().describe("Error description")
6327
7687
  }),
6328
7688
  execute: safeExecute("fail", async ({ error }) => {
6329
7689
  deps.onFail(error);
@@ -6333,13 +7693,13 @@ function failTool(deps) {
6333
7693
  }
6334
7694
 
6335
7695
  // src/services/tools/update-progress.ts
6336
- import { z as z22 } from "zod";
7696
+ import { z as z24 } from "zod";
6337
7697
  function updateProgressTool(deps) {
6338
7698
  return defineTool({
6339
7699
  description: "Report progress for the current task (replaces automatic step-based progress)",
6340
- parameters: z22.object({
6341
- message: z22.string().describe("Progress description"),
6342
- percent: z22.number().min(0).max(100).optional().describe("Completion percentage (0-100)")
7700
+ parameters: z24.object({
7701
+ message: z24.string().describe("Progress description"),
7702
+ percent: z24.number().min(0).max(100).optional().describe("Completion percentage (0-100)")
6343
7703
  }),
6344
7704
  execute: safeExecute("update_progress", async ({ message, percent }) => {
6345
7705
  deps.reportProgress(message, percent);
@@ -6350,21 +7710,24 @@ function updateProgressTool(deps) {
6350
7710
  }
6351
7711
 
6352
7712
  // src/services/tools/chat-worker.ts
6353
- import { z as z23 } from "zod";
7713
+ import { z as z25 } from "zod";
6354
7714
  function chatWorkerTool(deps) {
6355
7715
  return defineTool({
6356
- description: "Send a message to a running worker. Use this to provide additional guidance, correct the worker's approach, or ask the worker to check something. The worker reads it at its next step (puppet) or turn boundary (third-party); idle multi-stage workers read it when their next stage starts.",
6357
- parameters: z23.object({
6358
- workerId: z23.string().describe("ID of the target worker"),
6359
- message: z23.string().describe("Message to send to the worker")
7716
+ description: `Send a message to a running worker. Use this to provide additional guidance, correct the worker's approach, or ask the worker to check something. The worker reads it at its next step (puppet) or turn boundary (third-party); idle multi-stage workers read it when their next stage starts. A third-party worker NEVER acts on the message mid-tool-call: it finishes the current turn first, so "queued" can mean a delay of minutes. If the worker has already finished, this tool fails explicitly \u2014 inspect its output instead.`,
7717
+ parameters: z25.object({
7718
+ workerId: z25.string().describe("ID of the target worker"),
7719
+ message: z25.string().describe("Message to send to the worker")
6360
7720
  }),
6361
7721
  execute: safeExecute("chat_worker", async ({ workerId, message }) => {
6362
7722
  const status = deps.sendMessageToWorker(workerId, message);
6363
7723
  if (status === "not_running") {
6364
- return `[ERROR] Worker ${workerId} is not running (it may have just finished).`;
7724
+ return `[ERROR] Worker ${workerId} is not running (it may have just finished). Use get_worker_status("${workerId}") or read_worker_conversation("${workerId}") to inspect what it produced.`;
7725
+ }
7726
+ if (status === "finished") {
7727
+ return `[ERROR] Worker ${workerId} has already finished; message not delivered. Use read_worker_conversation("${workerId}") / get_task_detail to inspect its output.`;
6365
7728
  }
6366
7729
  if (status === "queued") {
6367
- return `Message queued for worker ${workerId} \u2014 it will be read when the current turn ends or the next stage starts.`;
7730
+ return `Message queued for worker ${workerId} \u2014 QUEUED only, not delivered yet: the worker reads it at its next step (puppet), turn boundary (third-party \u2014 the current turn, including any running tool call, finishes first) or next stage start (idle). A stuck worker will never reach that point. Verify with get_worker_status("${workerId}") (lastActivityAt / currentTool); if it looks stuck, use cancel_task instead of waiting for this message.`;
6368
7731
  }
6369
7732
  return `Message delivered to worker ${workerId} \u2014 it will be read at the worker's next step.`;
6370
7733
  })
@@ -6372,14 +7735,14 @@ function chatWorkerTool(deps) {
6372
7735
  }
6373
7736
 
6374
7737
  // src/services/tools/respond-permission.ts
6375
- import { z as z24 } from "zod";
7738
+ import { z as z26 } from "zod";
6376
7739
  function respondPermissionTool(deps) {
6377
7740
  return defineTool({
6378
7741
  description: "Respond to a worker's permission request. Approve to allow the operation, deny to reject it.",
6379
- parameters: z24.object({
6380
- requestId: z24.string().describe("The request ID from the permission_request signal"),
6381
- approved: z24.boolean().describe("true to approve, false to deny"),
6382
- reason: z24.string().optional().describe("Reason for denial (shown to worker)")
7742
+ parameters: z26.object({
7743
+ requestId: z26.string().describe("The request ID from the permission_request signal"),
7744
+ approved: z26.boolean().describe("true to approve, false to deny"),
7745
+ reason: z26.string().optional().describe("Reason for denial (shown to worker)")
6383
7746
  }),
6384
7747
  execute: safeExecute("respond_permission", async ({ requestId, approved, reason }) => {
6385
7748
  const handled = deps.respondPermission(requestId, approved, reason);
@@ -6390,13 +7753,13 @@ function respondPermissionTool(deps) {
6390
7753
  }
6391
7754
 
6392
7755
  // src/services/tools/respond-inquiry.ts
6393
- import { z as z25 } from "zod";
7756
+ import { z as z27 } from "zod";
6394
7757
  function respondInquiryTool(deps) {
6395
7758
  return defineTool({
6396
7759
  description: "Answer a worker's question (from a worker_inquiry signal). The blocked worker resumes with your answer as the tool result. If the decision belongs to the user, ask them first via inquire, then answer here.",
6397
- parameters: z25.object({
6398
- inquiryId: z25.string().describe("The inquiryId from the worker_inquiry signal"),
6399
- answer: z25.string().max(4e3).describe("The answer/instruction for the worker. Self-contained \u2014 the worker sees only this text.")
7760
+ parameters: z27.object({
7761
+ inquiryId: z27.string().describe("The inquiryId from the worker_inquiry signal"),
7762
+ answer: z27.string().max(4e3).describe("The answer/instruction for the worker. Self-contained \u2014 the worker sees only this text.")
6400
7763
  }),
6401
7764
  execute: safeExecute("respond_inquiry", async ({ inquiryId, answer }) => {
6402
7765
  const status = deps.respondInquiry(inquiryId, answer);
@@ -6412,7 +7775,7 @@ function respondInquiryTool(deps) {
6412
7775
  }
6413
7776
 
6414
7777
  // src/services/tools/use-skill.ts
6415
- import { z as z26 } from "zod";
7778
+ import { z as z28 } from "zod";
6416
7779
  function formatSkillContent(skill) {
6417
7780
  const header = `# Skill: ${skill.name}
6418
7781
 
@@ -6434,8 +7797,8 @@ ${skill.content}${resources}`;
6434
7797
  function useSkillTool(deps) {
6435
7798
  return defineTool({
6436
7799
  description: `Load a skill's full instructions by name. Skills are on-demand expertise (workflows, conventions, scripts). Use this when a task matches a skill in the "Skills" section. Returns the skill body plus a list of its resource files (invoke those with your shell/file tools, not here).`,
6437
- parameters: z26.object({
6438
- name: z26.string().describe('Skill name, exactly as shown in the "Skills" section')
7800
+ parameters: z28.object({
7801
+ name: z28.string().describe('Skill name, exactly as shown in the "Skills" section')
6439
7802
  }),
6440
7803
  execute: safeExecute("use-skill", async ({ name }) => {
6441
7804
  const content = deps.registry.get(name);
@@ -6467,7 +7830,26 @@ function createControllerTools(deps) {
6467
7830
  }),
6468
7831
  get_task_detail: getTaskDetailTool({
6469
7832
  getTask: deps.getTask,
6470
- getWorkerConversation: deps.getWorkerConversation
7833
+ getWorkerConversation: deps.getWorkerConversation,
7834
+ readTranscriptWindow: deps.readTranscriptWindow
7835
+ }),
7836
+ read_worker_conversation: readWorkerConversationTool({
7837
+ sessionDir: deps.sessionDir,
7838
+ getWorkerConversationByWorkerId: deps.getWorkerConversationByWorkerId,
7839
+ getWorkerTranscriptId: deps.getWorkerTranscriptId,
7840
+ readTranscriptWindow: (workerId, opts) => {
7841
+ const reader = deps.readTranscriptWindow;
7842
+ if (!reader) throw new Error("readTranscriptWindow dep not wired");
7843
+ return reader(workerId, opts);
7844
+ },
7845
+ listWorkers: () => deps.getActiveWorkers?.() ?? [],
7846
+ listTasks: () => deps.listTasks()
7847
+ }),
7848
+ get_worker_status: getWorkerStatusTool({
7849
+ listWorkers: () => deps.getActiveWorkers?.() ?? [],
7850
+ listTasks: () => deps.listTasks(),
7851
+ readTranscript: deps.readTranscript,
7852
+ getLiveStatus: deps.getWorkerLiveStatus
6471
7853
  }),
6472
7854
  update_task: updateTaskTool({ updateTask: deps.updateTask }),
6473
7855
  query_signals: querySignalsTool({ querySignals: deps.queryMessages }),
@@ -6507,13 +7889,13 @@ function createWorkerTools(deps) {
6507
7889
  }
6508
7890
 
6509
7891
  // src/services/tools/ask-controller.ts
6510
- import { z as z27 } from "zod";
7892
+ import { z as z29 } from "zod";
6511
7893
  function askControllerTool(deps) {
6512
7894
  return defineTool({
6513
7895
  description: "Ask the controller a question and WAIT for the answer. Use ONLY when you are blocked: execution cannot continue as instructed (contradiction, missing resource, unexpected state), or continuing would risk significant wasted work. Do NOT use it for decisions you can make yourself \u2014 decide those autonomously and report via update_progress / done.",
6514
- parameters: z27.object({
6515
- question: z27.string().max(500).describe("The specific question you need answered to proceed. Be concise and self-contained."),
6516
- context: z27.string().max(1e3).optional().describe("What you have tried and where you are stuck, so the controller can answer precisely.")
7896
+ parameters: z29.object({
7897
+ question: z29.string().max(500).describe("The specific question you need answered to proceed. Be concise and self-contained."),
7898
+ context: z29.string().max(1e3).optional().describe("What you have tried and where you are stuck, so the controller can answer precisely.")
6517
7899
  }),
6518
7900
  execute: safeExecute("ask_controller", async ({ question, context }) => {
6519
7901
  return deps.ask(question, context);
@@ -6555,16 +7937,16 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
6555
7937
  },
6556
7938
  ...deps.turnLineage ?? {}
6557
7939
  });
6558
- const result = await new Promise((resolve11) => {
7940
+ const result = await new Promise((resolve12) => {
6559
7941
  const timer = setTimeout(() => {
6560
7942
  pending.delete(requestId);
6561
- resolve11({ approved: false, reason: "timeout" });
7943
+ resolve12({ approved: false, reason: "timeout" });
6562
7944
  }, timeoutMs);
6563
7945
  pending.set(requestId, {
6564
7946
  resolve: (ok, reason) => {
6565
7947
  clearTimeout(timer);
6566
7948
  pending.delete(requestId);
6567
- resolve11({ approved: ok, reason });
7949
+ resolve12({ approved: ok, reason });
6568
7950
  },
6569
7951
  timer
6570
7952
  });
@@ -6601,14 +7983,14 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
6601
7983
  }
6602
7984
 
6603
7985
  // src/services/tools/permission-rules.ts
6604
- import { resolve as resolve7 } from "path";
7986
+ import { resolve as resolve8 } from "path";
6605
7987
  import { homedir } from "os";
6606
7988
  var FILE_TOOL_NAMES = ["read_file", "write_file", "edit_file", "search_files", "search_content"];
6607
7989
  var DANGEROUS_BASH_PATTERN = /(?:^|\||;|&&)\s*(?:rm\s|sudo\b|chmod\b|chown\b|mkfs\b|dd\s|shutdown\b|reboot\b|kill\s+-9)/;
6608
7990
  var URL_RE = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/\S+/g;
6609
7991
  function expandHome(p) {
6610
7992
  if (p === "~") return homedir();
6611
- if (p.startsWith("~/")) return resolve7(homedir(), p.slice(2));
7993
+ if (p.startsWith("~/")) return resolve8(homedir(), p.slice(2));
6612
7994
  return p;
6613
7995
  }
6614
7996
  function extractPathCandidates(command) {
@@ -6623,7 +8005,7 @@ function extractPathCandidates(command) {
6623
8005
  function needsFilePermission(workspace, args) {
6624
8006
  const rawPath = args.path;
6625
8007
  if (typeof rawPath !== "string" || !rawPath) return false;
6626
- return !isInsideWorkspace(resolve7(workspace, expandHome(rawPath)), workspace);
8008
+ return !isInsideWorkspace(resolve8(workspace, expandHome(rawPath)), workspace);
6627
8009
  }
6628
8010
  function needsBashPermission(workspace, command) {
6629
8011
  if (DANGEROUS_BASH_PATTERN.test(command)) return true;
@@ -6673,7 +8055,7 @@ function formatApiCallError(err) {
6673
8055
  }
6674
8056
 
6675
8057
  // src/services/task-execute-engine.ts
6676
- import { resolve as resolve8 } from "path";
8058
+ import { resolve as resolve9 } from "path";
6677
8059
  var RuntimeState = class extends BaseWayangState {
6678
8060
  constructor(initialData) {
6679
8061
  super(initialData, []);
@@ -6885,14 +8267,16 @@ var TaskExecuteEngine = class {
6885
8267
  * Send a message to a worker. Delivery is judged by the activeWorkers
6886
8268
  * runtime state — NOT by the workers Map, which lingers one tick past
6887
8269
  * disposal (removeWorkerTracking defers) and would accept messages for a
6888
- * worker that is already gone.
8270
+ * worker that is already gone. A worker that rejects the message (its run
8271
+ * can no longer take input) is reported as 'finished' — never a fake
8272
+ * 'queued'.
6889
8273
  */
6890
8274
  sendMessageToWorker(workerId, message) {
6891
8275
  const info = this.getActiveWorkers().find((w) => w.workerId === workerId);
6892
8276
  if (!info) return "not_running";
6893
8277
  const worker = this.workers.get(workerId);
6894
8278
  if (!worker) return "not_running";
6895
- worker.acceptMessage(message);
8279
+ if (!worker.acceptMessage(message)) return "finished";
6896
8280
  if (info.status === "idle") return "queued";
6897
8281
  return worker instanceof WorkerAgent ? "delivered" : "queued";
6898
8282
  }
@@ -6948,6 +8332,24 @@ var TaskExecuteEngine = class {
6948
8332
  }
6949
8333
  });
6950
8334
  }
8335
+ /**
8336
+ * Live run flags of a worker instance — "has the result arrived?" and "has
8337
+ * the stream closed?" (issue #16). Returns undefined for workers that do not
8338
+ * report them or are no longer tracked.
8339
+ */
8340
+ getWorkerLiveStatus(workerId) {
8341
+ return this.workers.get(workerId)?.getLiveStatus?.();
8342
+ }
8343
+ /**
8344
+ * Per-worker activity derived from the in-memory conversation: when it last
8345
+ * produced an entry, and which tool it called last. Fed into heartbeat
8346
+ * signals so the controller can spot a stall without calling a tool.
8347
+ */
8348
+ getWorkerActivity(workerId) {
8349
+ const entries = this.workers.get(workerId)?.getConversation() ?? [];
8350
+ const summary = summarizeTranscript(entries);
8351
+ return { lastActivityAt: summary.lastActivityAt, currentTool: summary.currentTool };
8352
+ }
6951
8353
  getWorkerConversation(taskId) {
6952
8354
  for (const [workerId, tId] of this.workerTaskMap) {
6953
8355
  if (tId === taskId) {
@@ -6957,6 +8359,25 @@ var TaskExecuteEngine = class {
6957
8359
  }
6958
8360
  return [];
6959
8361
  }
8362
+ /**
8363
+ * Live in-memory conversation of a worker looked up by worker id (not task
8364
+ * id). Undefined once the worker is no longer tracked — the on-disk
8365
+ * transcript (via the task's workerTranscriptId) takes over then.
8366
+ */
8367
+ getWorkerConversationByWorkerId(workerId) {
8368
+ const worker = this.workers.get(workerId);
8369
+ return worker === void 0 ? void 0 : worker.getConversation();
8370
+ }
8371
+ /**
8372
+ * The worker instance's OWN id (agent-…/cw-…) — the directory name under
8373
+ * `<sessionDir>/workers/` where its conversation.jsonl lives. Distinct from
8374
+ * the engine worker id (w-…) used in tasks/signals. Only resolvable while
8375
+ * the engine tracks the worker; afterwards the task's persisted
8376
+ * `workerTranscriptId` is the source of truth.
8377
+ */
8378
+ getWorkerTranscriptId(workerId) {
8379
+ return this.workers.get(workerId)?.id;
8380
+ }
6960
8381
  getRunningCount() {
6961
8382
  return this.taskState.get("tasks.running").length;
6962
8383
  }
@@ -7113,6 +8534,20 @@ var TaskExecuteEngine = class {
7113
8534
  this.taskState.append("tasks.running", updated);
7114
8535
  this.logger.info({ taskId: id, workerId: workerSessionId }, "Task moved to running");
7115
8536
  }
8537
+ /**
8538
+ * Record the worker instance's own id (the on-disk transcript directory
8539
+ * name) on the running task. Called right after the worker instance is
8540
+ * created, before its run starts, so the link is on disk even if the
8541
+ * process crashes mid-run.
8542
+ */
8543
+ stampWorkerTranscriptId(taskId, transcriptId) {
8544
+ const running = [...this.taskState.get("tasks.running")];
8545
+ const idx = running.findIndex((t) => t.id === taskId);
8546
+ if (idx === -1) return;
8547
+ if (running[idx].workerTranscriptId === transcriptId) return;
8548
+ running[idx] = { ...running[idx], workerTranscriptId: transcriptId };
8549
+ this.taskState.set("tasks.running", running);
8550
+ }
7116
8551
  completeTask(id, result) {
7117
8552
  this.moveToHistory(id, "completed", { result });
7118
8553
  this.logger.info({ taskId: id }, "Task completed");
@@ -7250,6 +8685,7 @@ var TaskExecuteEngine = class {
7250
8685
  const worker = this.createWorker(workerType);
7251
8686
  this.workers.set(workerId, worker);
7252
8687
  this.workerTaskMap.set(workerId, task.id);
8688
+ this.stampWorkerTranscriptId(task.id, worker.id);
7253
8689
  const executionTurnId = this.registerWorkerTurn(workerId, task);
7254
8690
  const onProgress = this.buildProgressEmitter(workerId, task, workerType);
7255
8691
  const tools = this.wireWorker(worker, task, workerId, workerType);
@@ -7280,7 +8716,9 @@ var TaskExecuteEngine = class {
7280
8716
  assignedFromWorkerId: workerId,
7281
8717
  status: "running",
7282
8718
  startedAt: Date.now(),
7283
- workerSessionId: workerId
8719
+ workerSessionId: workerId,
8720
+ // Same instance keeps writing the same conversation.jsonl.
8721
+ workerTranscriptId: worker.id
7284
8722
  };
7285
8723
  this.taskState.append("tasks.running", taskWithOrigin);
7286
8724
  this.workerTaskMap.set(workerId, task.id);
@@ -7308,10 +8746,22 @@ var TaskExecuteEngine = class {
7308
8746
  buildProgressEmitter(workerId, task, workerType) {
7309
8747
  const meta = getWorkerMeta(workerType, this.workerConfigs);
7310
8748
  return (msg) => {
8749
+ const activity = this.getWorkerActivity(workerId);
7311
8750
  this.enqueueWorkerSignal(workerId, {
7312
8751
  source: "worker",
7313
8752
  type: "progress",
7314
- payload: { workerId, taskId: task.id, taskTitle: task.title, workerType, emoji: meta.emoji, message: msg }
8753
+ payload: {
8754
+ workerId,
8755
+ taskId: task.id,
8756
+ taskTitle: task.title,
8757
+ workerType,
8758
+ emoji: meta.emoji,
8759
+ message: msg,
8760
+ // Activity rides along (issue #16) so the controller sees liveness
8761
+ // from the signal itself instead of polling.
8762
+ lastActivityAt: activity.lastActivityAt,
8763
+ currentTool: activity.currentTool
8764
+ }
7315
8765
  });
7316
8766
  };
7317
8767
  }
@@ -7361,10 +8811,20 @@ var TaskExecuteEngine = class {
7361
8811
  tavilyApiKey: this.ctx.config.tavilyApiKey,
7362
8812
  registry: this.skills,
7363
8813
  reportProgress: (msg, _percent) => {
8814
+ const activity = this.getWorkerActivity(workerId);
7364
8815
  this.enqueueWorkerSignal(workerId, {
7365
8816
  source: "worker",
7366
8817
  type: "progress",
7367
- payload: { workerId, taskId: task.id, taskTitle: task.title, workerType, emoji: workerMeta.emoji, message: msg }
8818
+ payload: {
8819
+ workerId,
8820
+ taskId: task.id,
8821
+ taskTitle: task.title,
8822
+ workerType,
8823
+ emoji: workerMeta.emoji,
8824
+ message: msg,
8825
+ lastActivityAt: activity.lastActivityAt,
8826
+ currentTool: activity.currentTool
8827
+ }
7368
8828
  });
7369
8829
  },
7370
8830
  onComplete: (summary) => {
@@ -7396,7 +8856,7 @@ var TaskExecuteEngine = class {
7396
8856
  if (toolName === "bash") return `Execute shell command: ${args.command}`;
7397
8857
  if (FILE_TOOL_NAMES.includes(toolName)) {
7398
8858
  const rawPath = typeof args.path === "string" ? args.path : "";
7399
- const resolved = rawPath ? resolve8(workspace, rawPath) : workspace;
8859
+ const resolved = rawPath ? resolve9(workspace, rawPath) : workspace;
7400
8860
  return `${toolName}: ${rawPath || "(workspace root)"} (resolved: ${resolved})`;
7401
8861
  }
7402
8862
  return `${toolName}: ${JSON.stringify(args)}`;
@@ -7556,7 +9016,7 @@ var ControllerLoop = class {
7556
9016
  this.signalQueue = signalQueue;
7557
9017
  this.controllerAgent = controllerAgent;
7558
9018
  this.heartbeatProvider = heartbeatProvider;
7559
- this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 3e4;
9019
+ this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 6e4;
7560
9020
  }
7561
9021
  ctx;
7562
9022
  signalQueue;
@@ -7632,13 +9092,21 @@ var ControllerLoop = class {
7632
9092
  payload: {
7633
9093
  reason: "Workers are running but no signals for a while. Review status and decide if action is needed.",
7634
9094
  idleSinceMs: now - lastWakeAt,
7635
- workers: activeWorkers.map((w) => ({
7636
- workerId: w.workerId,
7637
- taskId: w.taskId,
7638
- taskTitle: w.taskTitle,
7639
- workerType: w.workerType,
7640
- runningForMs: now - w.startedAt
7641
- })),
9095
+ workers: activeWorkers.map((w) => {
9096
+ const activity = this.heartbeatProvider.getWorkerActivity?.(w.workerId);
9097
+ return {
9098
+ workerId: w.workerId,
9099
+ taskId: w.taskId,
9100
+ taskTitle: w.taskTitle,
9101
+ workerType: w.workerType,
9102
+ runningForMs: now - w.startedAt,
9103
+ ...activity ? {
9104
+ lastActivityAt: activity.lastActivityAt,
9105
+ lastActivityAgoMs: activity.lastActivityAt === null ? null : Math.max(0, now - activity.lastActivityAt),
9106
+ currentTool: activity.currentTool
9107
+ } : {}
9108
+ };
9109
+ }),
7642
9110
  pendingTaskCount: this.heartbeatProvider.getPendingCount()
7643
9111
  }
7644
9112
  });
@@ -7655,11 +9123,11 @@ var ControllerLoop = class {
7655
9123
  import * as path5 from "path";
7656
9124
 
7657
9125
  // src/services/session/session-state.ts
7658
- import { join as join8 } from "path";
9126
+ import { join as join10 } from "path";
7659
9127
  var SessionState = class extends BaseWayangState {
7660
9128
  jsonFile;
7661
9129
  constructor(sessionDir) {
7662
- const jsonFile = new JSONFileHelper(join8(sessionDir, "meta.json"));
9130
+ const jsonFile = new JSONFileHelper(join10(sessionDir, "meta.json"));
7663
9131
  super(
7664
9132
  {
7665
9133
  meta: {
@@ -7765,7 +9233,7 @@ var SessionManager = class _SessionManager {
7765
9233
  };
7766
9234
 
7767
9235
  // src/services/agents/controller-state.ts
7768
- import { join as join10 } from "path";
9236
+ import { join as join12 } from "path";
7769
9237
 
7770
9238
  // src/services/conversation/conversation-index.ts
7771
9239
  function emptyUsage() {
@@ -7986,8 +9454,8 @@ var ControllerAgentState = class extends BaseWayangState {
7986
9454
  /** In-memory resolve function for the current inquiry — never persisted. */
7987
9455
  inquiryResolver = null;
7988
9456
  constructor(ctx) {
7989
- const jsonFile = new JSONFileHelper(join10(ctx.sessionDir, "runtime-state.json"));
7990
- const jsonlFile = new JSONLFileHelper(join10(ctx.sessionDir, "conversation.jsonl"));
9457
+ const jsonFile = new JSONFileHelper(join12(ctx.sessionDir, "runtime-state.json"));
9458
+ const jsonlFile = new JSONLFileHelper(join12(ctx.sessionDir, "conversation.jsonl"));
7991
9459
  const initialData = {
7992
9460
  runtimeState: {
7993
9461
  session: { id: "", startedAt: 0 },
@@ -8062,8 +9530,8 @@ var ControllerAgentState = class extends BaseWayangState {
8062
9530
  }
8063
9531
  /** Set a pending inquiry and return a Promise that resolves when the user answers. */
8064
9532
  askInquiry(question) {
8065
- return new Promise((resolve11) => {
8066
- this.inquiryResolver = resolve11;
9533
+ return new Promise((resolve12) => {
9534
+ this.inquiryResolver = resolve12;
8067
9535
  this.set("runtimeState.pendingInquiry", question);
8068
9536
  });
8069
9537
  }
@@ -8223,6 +9691,19 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
8223
9691
  listTasks: (status) => engine.list(status),
8224
9692
  getTask: (taskId) => engine.get(taskId),
8225
9693
  getWorkerConversation: (taskId) => engine.getWorkerConversation(taskId),
9694
+ // On-disk transcript access (issue #16): survives worker disposal, so a
9695
+ // finished/cancelled worker's scene stays readable by the controller.
9696
+ readTranscript: (workerId) => readWorkerTranscript(ctx.sessionDir, workerId),
9697
+ // Layered transcript access (issue #16 review): while the engine tracks
9698
+ // the worker, its live in-memory conversation is the source; the on-disk
9699
+ // jsonl lives under the worker instance's OWN id (agent-/cw-…), which
9700
+ // the engine exposes and tasks persist as workerTranscriptId.
9701
+ getWorkerConversationByWorkerId: (workerId) => engine.getWorkerConversationByWorkerId(workerId),
9702
+ getWorkerTranscriptId: (workerId) => engine.getWorkerTranscriptId(workerId),
9703
+ readTranscriptWindow: (workerId, opts2) => readWorkerTranscriptWindow(ctx.sessionDir, workerId, opts2),
9704
+ sessionDir: ctx.sessionDir,
9705
+ getActiveWorkers: () => engine.getActiveWorkers(),
9706
+ getWorkerLiveStatus: (workerId) => engine.getWorkerLiveStatus(workerId),
8226
9707
  cancelTask: (taskId) => engine.cancel(taskId),
8227
9708
  abortWorker: (taskId) => engine.abortByTaskId(taskId),
8228
9709
  updateTask: (taskId, updates) => engine.updatePending(taskId, updates),
@@ -8522,6 +10003,14 @@ function isoMs(ms) {
8522
10003
  if (typeof ms !== "number" || !Number.isFinite(ms)) return void 0;
8523
10004
  return new Date(ms).toISOString();
8524
10005
  }
10006
+ function signalActivityTail(currentTool, lastActivityAt) {
10007
+ const parts = [];
10008
+ if (currentTool) parts.push(`tool=${currentTool}`);
10009
+ if (typeof lastActivityAt === "number" && Number.isFinite(lastActivityAt)) {
10010
+ parts.push(`lastEntry=${new Date(lastActivityAt).toISOString().replace(/\.\d{3}Z$/, "Z")}`);
10011
+ }
10012
+ return parts.length > 0 ? `[activity: ${parts.join(", ")}]` : null;
10013
+ }
8525
10014
  var signalConverters = {
8526
10015
  input: (sig, ts, sessionId) => ({
8527
10016
  type: "user" /* User */,
@@ -8563,6 +10052,7 @@ var signalConverters = {
8563
10052
  },
8564
10053
  progress: (sig, ts, sessionId) => {
8565
10054
  const p = sig.payload;
10055
+ const activity = signalActivityTail(p.currentTool, p.lastActivityAt);
8566
10056
  return {
8567
10057
  ...signalBase(ts, sessionId),
8568
10058
  subtype: "worker_progress" /* WorkerProgress */,
@@ -8571,12 +10061,17 @@ var signalConverters = {
8571
10061
  emoji: p.emoji,
8572
10062
  taskId: p.taskId,
8573
10063
  taskTitle: p.taskTitle,
8574
- content: p.message
10064
+ content: activity ? `${p.message}
10065
+ ${activity}` : p.message
8575
10066
  };
8576
10067
  },
8577
10068
  heartbeat: (sig, ts, sessionId) => {
8578
10069
  const p = sig.payload;
8579
- const workerSummary = p.workers.map((w) => `${w.taskTitle} (${w.workerType}, running ${Math.round(w.runningForMs / 1e3)}s)`).join(", ");
10070
+ const workerSummary = p.workers.map((w) => {
10071
+ const tool2 = w.currentTool ? `, tool ${w.currentTool}` : "";
10072
+ const idle = w.lastActivityAgoMs !== null && w.lastActivityAgoMs !== void 0 ? `, last activity ${Math.round(w.lastActivityAgoMs / 1e3)}s ago` : "";
10073
+ return `${w.taskTitle} [${w.workerId}] (${w.workerType}, running ${Math.round(w.runningForMs / 1e3)}s${tool2}${idle})`;
10074
+ }).join(", ");
8580
10075
  return {
8581
10076
  ...signalBase(ts, sessionId),
8582
10077
  subtype: "heartbeat" /* Heartbeat */,
@@ -8739,8 +10234,8 @@ function signalToEntry(sig, ts, sessionId) {
8739
10234
  }
8740
10235
 
8741
10236
  // src/services/skills/registry.ts
8742
- import { readFileSync as readFileSync8, readdirSync as readdirSync2, statSync } from "fs";
8743
- import { join as join11 } from "path";
10237
+ import { readFileSync as readFileSync9, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
10238
+ import { join as join13 } from "path";
8744
10239
  import { parse as parseYaml } from "yaml";
8745
10240
  var SKILL_FILE = "SKILL.md";
8746
10241
  function parseFrontmatter(raw) {
@@ -8775,7 +10270,7 @@ function listResources(skillDir) {
8775
10270
  const walk = (relDir) => {
8776
10271
  let entries;
8777
10272
  try {
8778
- entries = readdirSync2(join11(skillDir, relDir), { withFileTypes: true });
10273
+ entries = readdirSync2(join13(skillDir, relDir), { withFileTypes: true });
8779
10274
  } catch {
8780
10275
  return;
8781
10276
  }
@@ -8822,10 +10317,10 @@ var SkillRegistry = class {
8822
10317
  if (existing) return existing;
8823
10318
  const desc = this.descriptors.get(name);
8824
10319
  if (!desc) return void 0;
8825
- const filePath = join11(desc.dir, SKILL_FILE);
10320
+ const filePath = join13(desc.dir, SKILL_FILE);
8826
10321
  let raw;
8827
10322
  try {
8828
- raw = readFileSync8(filePath, "utf-8");
10323
+ raw = readFileSync9(filePath, "utf-8");
8829
10324
  } catch (err) {
8830
10325
  this.logger?.warn({ skill: name, error: err instanceof Error ? err.message : String(err) }, "Failed to read skill file");
8831
10326
  return void 0;
@@ -8853,8 +10348,8 @@ var SkillRegistry = class {
8853
10348
  for (const entry of entries) {
8854
10349
  if (!entry.isDirectory()) continue;
8855
10350
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
8856
- const skillDir = join11(dir, entry.name);
8857
- const skillFile = join11(skillDir, SKILL_FILE);
10351
+ const skillDir = join13(dir, entry.name);
10352
+ const skillFile = join13(skillDir, SKILL_FILE);
8858
10353
  if (!existsAsFile(skillFile)) continue;
8859
10354
  const { frontmatter } = safeReadFrontmatter(skillFile);
8860
10355
  const description = asString(frontmatter.description);
@@ -8869,35 +10364,35 @@ var SkillRegistry = class {
8869
10364
  };
8870
10365
  function existsAsFile(path7) {
8871
10366
  try {
8872
- return statSync(path7).isFile();
10367
+ return statSync3(path7).isFile();
8873
10368
  } catch {
8874
10369
  return false;
8875
10370
  }
8876
10371
  }
8877
10372
  function safeReadFrontmatter(path7) {
8878
10373
  try {
8879
- return parseFrontmatter(readFileSync8(path7, "utf-8"));
10374
+ return parseFrontmatter(readFileSync9(path7, "utf-8"));
8880
10375
  } catch {
8881
10376
  return { frontmatter: {}, body: "" };
8882
10377
  }
8883
10378
  }
8884
10379
 
8885
10380
  // src/services/skills/skill-dir.ts
8886
- import { existsSync as existsSync6 } from "fs";
10381
+ import { existsSync as existsSync8 } from "fs";
8887
10382
  import { homedir as homedir2 } from "os";
8888
- import { join as join12, resolve as resolve9 } from "path";
10383
+ import { join as join14, resolve as resolve10 } from "path";
8889
10384
  function resolveSkillDirs(workspaceDir2, configDirs) {
8890
10385
  const candidates = [
8891
- join12(homedir2(), ".wayang", "skills"),
8892
- join12(workspaceDir2, ".wayang", "skills"),
8893
- ...(configDirs ?? []).map((d) => resolve9(d))
10386
+ join14(homedir2(), ".wayang", "skills"),
10387
+ join14(workspaceDir2, ".wayang", "skills"),
10388
+ ...(configDirs ?? []).map((d) => resolve10(d))
8894
10389
  ];
8895
10390
  const seen = /* @__PURE__ */ new Set();
8896
10391
  const dirs = [];
8897
10392
  for (const dir of candidates) {
8898
10393
  if (seen.has(dir)) continue;
8899
10394
  seen.add(dir);
8900
- if (existsSync6(dir)) dirs.push(dir);
10395
+ if (existsSync8(dir)) dirs.push(dir);
8901
10396
  }
8902
10397
  return dirs;
8903
10398
  }
@@ -8956,7 +10451,8 @@ var Supervisor = class {
8956
10451
  {
8957
10452
  getRunningCount: () => this.engine.getRunningCount(),
8958
10453
  getActiveWorkers: () => this.engine.getActiveWorkers(),
8959
- getPendingCount: () => this.engine.list("pending").length
10454
+ getPendingCount: () => this.engine.list("pending").length,
10455
+ getWorkerActivity: (workerId) => this.engine.getWorkerActivity(workerId)
8960
10456
  }
8961
10457
  );
8962
10458
  }
@@ -9245,8 +10741,11 @@ async function runWayangServer(options, io = process) {
9245
10741
  return { status };
9246
10742
  });
9247
10743
  rpc.register(METHOD.CHAT_WORKER, (params) => {
9248
- supervisor.engine.sendMessageToWorker(requiredString(params, "workerId"), requiredString(params, "message"));
9249
- return {};
10744
+ const status = supervisor.engine.sendMessageToWorker(
10745
+ requiredString(params, "workerId"),
10746
+ requiredString(params, "message")
10747
+ );
10748
+ return { status };
9250
10749
  });
9251
10750
  rpc.register(METHOD.DISPOSE_WORKER, (params) => {
9252
10751
  supervisor.engine.abortByWorkerId(requiredString(params, "workerId"));