loop-task 1.4.7 → 1.5.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.
package/README.md CHANGED
@@ -252,6 +252,78 @@ loop-task (board) ──IPC──► daemon ──► loop 1 ──► task (com
252
252
  - **Persistent** - loop and task state is saved after every run; survives restarts
253
253
  - **Graceful shutdown** - background loops are daemon-managed; foreground loops finish the current execution on Ctrl+C
254
254
 
255
+ ## Chain Context Sharing
256
+
257
+ When tasks are arranged in a chain (on-success or on-failure), context flows between them automatically. This lets later tasks reference output from earlier ones without custom glue.
258
+
259
+ ### How it works
260
+
261
+ 1. **Auto-capture** - stdout from every task in the chain is captured before the next task starts.
262
+ 2. **Parse rules** - captured output is parsed by content type:
263
+ - **JSON object** (`{"key": "value"}`) - each key is merged into the shared context.
264
+ - **JSONL** (one JSON object per line) - each line's keys are merged in order.
265
+ - **Plain text** - stored under a single `output` key.
266
+ - **Empty output** - no change to context.
267
+ 3. **Template interpolation** - use `{{key}}` in the command or arguments of any task. Before spawning, `{{key}}` is replaced with the current value of `key` from the shared context.
268
+ 4. **Merge semantics** - keys accumulate across the chain. Task 1 produces `{ "id": "42" }`, task 2 can use `{{id}}` and also add `{ "status": "ok" }`. Task 3 sees both.
269
+ 5. **Output clobbering** - plain text tasks overwrite the `output` key. Use JSON with named keys when data must survive across multiple downstream tasks.
270
+ 6. **Context lifecycle** - context is built fresh each loop iteration and exists only in memory. It is never persisted to disk.
271
+
272
+ ### Example: Issue Refinement Chain
273
+
274
+ A four-task chain that finds an issue, marks it in-progress, rewrites it with AI, and relabels it - all without re-querying:
275
+
276
+ **Task 1** (primary): Find an issue to refine
277
+
278
+ ```bash
279
+ gh issue list --label "to refine" --limit 1 --json number,title,body --jq '{number: .[0].number, title: .[0].title, body: .[0].body}'
280
+ ```
281
+
282
+ stdout: `{"number":123,"title":"Fix login","body":"It doesn't work"}`
283
+ context: `{ number: 123, title: "Fix login", body: "It doesn't work" }`
284
+
285
+ **Task 2** (chain, onSuccess): Mark as in-progress
286
+
287
+ ```bash
288
+ gh issue edit {{number}} --add-label "refining" --remove-label "to refine"
289
+ ```
290
+
291
+ interpolated: `gh issue edit 123 --add-label "refining" --remove-label "to refine"`
292
+
293
+ **Task 3** (chain, onSuccess): Rewrite with AI
294
+
295
+ ```bash
296
+ opencode run "Rewrite this GitHub issue as a detailed user story using project context and return only JSON with fields title and body." --model "opencode/big-pickle"
297
+ ```
298
+
299
+ stdout: `{"title":"As a user, I want to log in securely","body":"## Acceptance Criteria\n- Login form validates email\n- ..."}`
300
+ context: `{ number: 123, title: "As a user, I want to log in securely", body: "## Acceptance Criteria\n- ..." }`
301
+
302
+ **Task 4** (chain, onSuccess): Apply the rewrite and relabel
303
+
304
+ ```bash
305
+ gh issue edit {{number}} --title "{{title}}" --body "{{body}}" --remove-label "refining" --add-label "to implement"
306
+ ```
307
+
308
+ interpolated: `gh issue edit 123 --title "As a user, I want to log in securely" --body "## Acceptance Criteria\n- ..." --remove-label "refining" --add-label "to implement"`
309
+
310
+ ### How it works
311
+
312
+ 1. Task 1 queries the issue and emits a JSON object with `number`, `title`, and `body` via `--jq`. The primary task cannot use `{{key}}` interpolation because the chain context is empty when it runs.
313
+ 2. Task 2 receives `{{number}}` interpolated from task 1's context. It relabels the issue from "to refine" to "refining" - no re-query needed.
314
+ 3. Task 3 runs opencode, which finds the issue by the "refining" label and rewrites it. It outputs a new JSON object with updated `title` and `body`. Since it uses the same key names, the context is updated with the new values (merge with last-writer-wins).
315
+ 4. Task 4 receives `{{number}}` (still 123 from task 1), `{{title}}` and `{{body}}` (now the rewritten versions from task 3). It applies the edits and relabels the issue as "to implement" - no re-query needed.
316
+
317
+ ### Wrapping values with --jq
318
+
319
+ To avoid the plain-text `output` clobbering, wrap any value in a named JSON key using `--jq` (requires `--json` before `--jq`):
320
+
321
+ ```bash
322
+ gh issue list --label "to refine" --json number,title --jq '{number: .[0].number, title: .[0].title}'
323
+ ```
324
+
325
+ This stores `{ "number": 123, "title": "Fix login" }` in context instead of overwriting `output`.
326
+
255
327
  ## Development
256
328
 
257
329
  Requires [Bun](https://bun.sh) >= 1.2 for package management and the board, and [Node.js](https://nodejs.org) >= 20 for the CLI and daemon.
package/dist/board/App.js CHANGED
@@ -14,6 +14,7 @@ import { Inspector } from "./components/Inspector.js";
14
14
  import { RunHistory } from "./components/RunHistory.js";
15
15
  import { ActionButtons } from "./components/ActionButtons.js";
16
16
  import { HelpModal } from "./components/HelpModal.js";
17
+ import { ContextHelpModal } from "./components/ContextHelpModal.js";
17
18
  import { Footer } from "./components/Footer.js";
18
19
  import { ConfirmModal } from "./components/ConfirmModal.js";
19
20
  import { CreateView, createInitialValues } from "./components/CreateForm.js";
@@ -58,6 +59,7 @@ export function App(props) {
58
59
  const [selectedIndex, setSelectedIndex] = useState(0);
59
60
  const [searchActive, setSearchActive] = useState(false);
60
61
  const [helpOpen, setHelpOpen] = useState(false);
62
+ const [contextHelpOpen, setContextHelpOpen] = useState(false);
61
63
  const [confirm, setConfirm] = useState(null);
62
64
  const [confirmChoice, setConfirmChoice] = useState(0);
63
65
  const [editTarget, setEditTarget] = useState(null);
@@ -341,6 +343,7 @@ export function App(props) {
341
343
  onEnterHeader: (direction) => {
342
344
  setFocusedPanel(direction === "right" ? "header-tasks" : "header-new");
343
345
  },
346
+ onToggleContextHelp: () => setContextHelpOpen((v) => !v),
344
347
  selectable: stack.includes("create") || stack.includes("task-edit"),
345
348
  });
346
349
  const counts = {
@@ -361,6 +364,6 @@ export function App(props) {
361
364
  } })) : (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(Navigator, { visible: visible, total: loops.length, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, focused: focusedPanel === "loops", projects: projects, onSelect: (index) => { setSelectedIndex(index); setFocusedPanel("loops"); }, onActivate: (index) => { setSelectedIndex(index); const loop = visible[index]; if (loop) {
362
365
  setEditTarget(loop);
363
366
  push("create");
364
- } } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused: focusedPanel === "runs", onSelectRun: (index) => { setSelectedRunIndex(index); setFocusedPanel("runs"); }, onOpenRun: handleOpenRunLog }), _jsx(ActionButtons, { loop: selected, focused: focusedPanel === "actions", selectedAction: selectedAction, onAction: handleAction })] })] })) }, viewKey(view, editTarget, editTask)), _jsx(Footer, { mode: mode }), confirm ? (_jsx(ConfirmModal, { message: confirm.message, choice: confirmChoice, onYes: () => { const action = confirm.action; setConfirm(null); void action(); }, onNo: () => setConfirm(null) })) : null, helpOpen ? _jsx(HelpModal, { view: view }) : null, logModalRun ? (_jsx(LogModal, { loopId: selectedId, run: logModalRun, logLines: logModalLines, loading: logModalLoading, onClose: () => setLogModalRun(null) })) : null, projectsModalOpen ? (_jsx(ProjectsModal, { projects: projects, loops: loops, currentProjectId: currentProjectId, onSelect: (id) => { setCurrentProjectId(id); setProjectsModalOpen(false); }, onClose: () => setProjectsModalOpen(false) })) : null, _jsx(ToastStack, { toasts: toasts })] }));
367
+ } } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused: focusedPanel === "runs", onSelectRun: (index) => { setSelectedRunIndex(index); setFocusedPanel("runs"); }, onOpenRun: handleOpenRunLog }), _jsx(ActionButtons, { loop: selected, focused: focusedPanel === "actions", selectedAction: selectedAction, onAction: handleAction })] })] })) }, viewKey(view, editTarget, editTask)), _jsx(Footer, { mode: mode }), confirm ? (_jsx(ConfirmModal, { message: confirm.message, choice: confirmChoice, onYes: () => { const action = confirm.action; setConfirm(null); void action(); }, onNo: () => setConfirm(null) })) : null, helpOpen ? _jsx(HelpModal, { view: view }) : null, contextHelpOpen ? _jsx(ContextHelpModal, { onClose: () => setContextHelpOpen(false) }) : null, logModalRun ? (_jsx(LogModal, { loopId: selectedId, run: logModalRun, logLines: logModalLines, loading: logModalLoading, onClose: () => setLogModalRun(null) })) : null, projectsModalOpen ? (_jsx(ProjectsModal, { projects: projects, loops: loops, currentProjectId: currentProjectId, onSelect: (id) => { setCurrentProjectId(id); setProjectsModalOpen(false); }, onClose: () => setProjectsModalOpen(false) })) : null, _jsx(ToastStack, { toasts: toasts })] }));
365
368
  }
366
369
  const TASK_FORM_VIEWS = new Set(["task-create", "task-edit"]);
@@ -2,22 +2,22 @@ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
2
  import { t } from "../../i18n/index.js";
3
3
  import { useHoverState } from "../hooks/useHoverState.js";
4
4
  import { HOVER_BG } from "../../config/constants.js";
5
- export function getActions(status) {
5
+ const STOP_ACTION = { key: "stop", label: t("board.actionStop") };
6
+ const PAUSE_ACTION = { key: "pause", label: t("board.actionPause") };
7
+ const PLAY_ACTION = { key: "play", label: t("board.actionPlay") };
8
+ const STATUS_ACTIONS = {
9
+ running: [STOP_ACTION],
10
+ waiting: [PAUSE_ACTION, STOP_ACTION],
11
+ paused: [STOP_ACTION, PLAY_ACTION],
12
+ idle: [PLAY_ACTION],
13
+ stopped: [PLAY_ACTION],
14
+ };
15
+ function getActions(status) {
6
16
  const actions = [
7
17
  { key: "edit", label: t("board.actionEdit") },
8
18
  { key: "delete", label: t("board.actionDelete") },
9
19
  ];
10
- if (status === "waiting") {
11
- actions.push({ key: "pause", label: t("board.actionPause") });
12
- actions.push({ key: "stop", label: t("board.actionStop") });
13
- }
14
- else if (status === "paused") {
15
- actions.push({ key: "stop", label: t("board.actionStop") });
16
- actions.push({ key: "play", label: t("board.actionPlay") });
17
- }
18
- else if (status === "idle" || status === "stopped") {
19
- actions.push({ key: "play", label: t("board.actionPlay") });
20
- }
20
+ actions.push(...STATUS_ACTIONS[status]);
21
21
  if (status !== "running") {
22
22
  actions.push({ key: "clone", label: t("board.actionClone") });
23
23
  actions.push({ key: "trigger", label: t("board.actionTrigger") });
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useTerminalDimensions } from "@opentui/react";
3
+ import { t } from "../../i18n/index.js";
4
+ export function ContextHelpModal(props) {
5
+ const { onClose } = props;
6
+ const { width } = useTerminalDimensions();
7
+ return (_jsx("box", { onMouseDown: onClose, style: {
8
+ position: "absolute",
9
+ top: 0,
10
+ left: 0,
11
+ width: "100%",
12
+ height: "100%",
13
+ justifyContent: "center",
14
+ alignItems: "center",
15
+ zIndex: 90,
16
+ }, children: _jsxs("box", { title: t("context.helpTitle"), border: true, style: {
17
+ flexDirection: "column",
18
+ padding: 1,
19
+ minWidth: Math.min(72, width - 4),
20
+ backgroundColor: "#111827",
21
+ }, children: [_jsx("text", { fg: "#e5e7eb", children: t("context.helpRules") }), _jsx("text", { children: " " }), _jsx("text", { fg: "#38bdf8", children: "{{key}} Templates" }), _jsx("text", { fg: "#e5e7eb", children: t("context.helpTemplates") }), _jsx("text", { children: " " }), _jsx("text", { fg: "#f59e0b", children: "Caveat" }), _jsx("text", { fg: "#e5e7eb", children: t("context.helpCaveat") }), _jsx("text", { children: " " }), _jsx("text", { fg: "#34d399", children: "jq Tip" }), _jsx("text", { fg: "#e5e7eb", children: t("context.helpJqTip") }), _jsx("text", { children: " " }), _jsx("text", { fg: "#9ca3af", children: "Press any key or click to close" })] }) }));
22
+ }
@@ -8,6 +8,7 @@ import { commandLine } from "../format.js";
8
8
  import { createLoop, updateLoop, listTasks } from "../daemon.js";
9
9
  import { useHoverState } from "../hooks/useHoverState.js";
10
10
  import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
11
+ import { SearchSelect } from "./SearchSelect.js";
11
12
  import { HOVER_BG } from "../../config/constants.js";
12
13
  export const TASK_MODE_INLINE = "inline";
13
14
  export const TASK_MODE_EXISTING = "existing";
@@ -104,17 +105,8 @@ export function CreateView(props) {
104
105
  }
105
106
  if (key.name === "up" || key.name === "down") {
106
107
  const field = filteredFields[focusIndex];
107
- if (field === "project" && projectOptions.length > 1) {
108
- const currentIdx = projectOptions.findIndex((p) => p.value === valuesRef.current.project);
109
- const nextIdx = key.name === "down"
110
- ? (currentIdx + 1) % projectOptions.length
111
- : (currentIdx - 1 + projectOptions.length) % projectOptions.length;
112
- const next = projectOptions[nextIdx];
113
- if (next)
114
- updateValues({ ...valuesRef.current, project: next.value });
115
- key.preventDefault();
108
+ if (field === "project")
116
109
  return;
117
- }
118
110
  setFocusIndex((i) => {
119
111
  const next = key.name === "up" ? i - 1 : i + 1;
120
112
  if (next < 0)
@@ -301,10 +293,7 @@ function FormRow(props) {
301
293
  return (_jsx("box", { onMouseDown: () => updateValues({ ...valuesRef.current, [field]: opt.value }), style: { backgroundColor: isActive ? "#1e3a8a" : undefined, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, ...hoverProps, children: _jsx("text", { fg: isActive ? "#ffffff" : isHovered ? "#e5e7eb" : "#9ca3af", children: _jsx("strong", { children: opt.name }) }) }, opt.value));
302
294
  }) })) : isTaskButton ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", paddingLeft: 1 }, onMouseDown: () => { setFocusIndex(index); onChooseTask(); }, children: _jsx("text", { fg: values.taskId ? "#4ade80" : "#9ca3af", children: values.taskId
303
295
  ? t("board.selectedTask", { name: selectedTaskName ?? values.taskId })
304
- : t("board.chooseTask") }) })) : isProjectField ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", paddingLeft: 1 }, onMouseDown: () => setFocusIndex(index), children: projectOptions.map((opt) => {
305
- const isActive = values.project === opt.value;
306
- return (_jsx("box", { onMouseDown: () => updateValues({ ...valuesRef.current, project: opt.value }), style: { backgroundColor: isActive ? "#1e3a8a" : undefined, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, children: _jsx("text", { fg: isActive ? "#ffffff" : "#9ca3af", children: `● ${opt.name}` }) }, opt.value));
307
- }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: examples[field] ? t("board.placeholderExample", { example: examples[field] }) : t("board.placeholderBlank"), onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
296
+ : t("board.chooseTask") }) })) : isProjectField ? (_jsx(SearchSelect, { options: projectOptions, value: values.project, onChange: (v) => updateValues({ ...valuesRef.current, project: v }), focused: isFocused })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: examples[field] ? t("board.placeholderExample", { example: examples[field] }) : t("board.placeholderBlank"), onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
308
297
  if (index < fields.length - 1) {
309
298
  setFocusIndex(index + 1);
310
299
  }
@@ -0,0 +1,99 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useRef, useState, useMemo } from "react";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import { t } from "../../i18n/index.js";
5
+ import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
6
+ import { SEARCH_SELECT_HEIGHT } from "../../config/constants.js";
7
+ export function SearchSelect(props) {
8
+ const { options, value, onChange, focused, height } = props;
9
+ const placeholder = props.placeholder ?? t("board.searchSelectPlaceholder");
10
+ const maxHeight = height ?? SEARCH_SELECT_HEIGHT;
11
+ const inputRef = useRef(null);
12
+ const [filter, setFilter] = useState("");
13
+ const [selectedIndex, setSelectedIndex] = useState(0);
14
+ useInputShortcuts(() => focused ? inputRef.current : null);
15
+ const filtered = useMemo(() => {
16
+ if (!filter)
17
+ return options;
18
+ const q = filter.toLowerCase();
19
+ return options.filter((o) => o.name.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
20
+ }, [options, filter]);
21
+ const currentIdx = filtered.findIndex((o) => o.value === value);
22
+ const safeSelected = currentIdx >= 0 ? currentIdx : selectedIndex;
23
+ const clampedSelected = Math.min(safeSelected, Math.max(0, filtered.length - 1));
24
+ useKeyboard((key) => {
25
+ if (!focused)
26
+ return;
27
+ const name = key.name;
28
+ if (name === "up" || name === "k") {
29
+ if (filtered.length > 0) {
30
+ setSelectedIndex((i) => i <= 0 ? filtered.length - 1 : i - 1);
31
+ }
32
+ key.preventDefault();
33
+ key.stopPropagation();
34
+ return;
35
+ }
36
+ if (name === "down" || name === "j") {
37
+ if (filtered.length > 0) {
38
+ setSelectedIndex((i) => i >= filtered.length - 1 ? 0 : i + 1);
39
+ }
40
+ key.preventDefault();
41
+ key.stopPropagation();
42
+ return;
43
+ }
44
+ if (name === "return" || name === "enter") {
45
+ const option = filtered[clampedSelected];
46
+ if (option) {
47
+ onChange(option.value);
48
+ }
49
+ key.preventDefault();
50
+ key.stopPropagation();
51
+ return;
52
+ }
53
+ if (name === "escape") {
54
+ if (filter) {
55
+ setFilter("");
56
+ setSelectedIndex(0);
57
+ }
58
+ key.preventDefault();
59
+ key.stopPropagation();
60
+ return;
61
+ }
62
+ if (name === "backspace") {
63
+ setFilter((f) => f.slice(0, -1));
64
+ setSelectedIndex(0);
65
+ key.preventDefault();
66
+ key.stopPropagation();
67
+ return;
68
+ }
69
+ if (key.ctrl)
70
+ return;
71
+ if (name && name.length === 1 && /[a-z0-9 _\-./]/i.test(name)) {
72
+ setFilter((f) => f + name);
73
+ setSelectedIndex(0);
74
+ key.preventDefault();
75
+ key.stopPropagation();
76
+ return;
77
+ }
78
+ if (key.sequence && key.sequence.length === 1 && /[a-z0-9 _\-./]/i.test(key.sequence)) {
79
+ setFilter((f) => f + key.sequence);
80
+ setSelectedIndex(0);
81
+ key.preventDefault();
82
+ key.stopPropagation();
83
+ return;
84
+ }
85
+ });
86
+ const listHeight = Math.min(filtered.length, maxHeight);
87
+ return (_jsxs("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { style: { height: 3, flexDirection: "row", alignItems: "center" }, children: [_jsx("text", { fg: "#6b7280", children: " / " }), _jsx("input", { ref: inputRef, focused: focused, value: filter, placeholder: placeholder, onInput: (v) => {
88
+ setFilter(v);
89
+ setSelectedIndex(0);
90
+ } })] }), _jsx("box", { style: { flexDirection: "column", height: listHeight }, children: filtered.map((option, i) => {
91
+ const isSelected = i === clampedSelected;
92
+ const isActive = option.value === value;
93
+ const prefix = isSelected ? "› " : " ";
94
+ const colorIndicator = option.color ? `\u25cf ` : "";
95
+ const bg = isSelected ? "#1e3a8a" : undefined;
96
+ const fg = isSelected ? "#ffffff" : isActive ? "#38bdf8" : "#9ca3af";
97
+ return (_jsx("box", { style: { flexDirection: "row", backgroundColor: bg }, children: _jsx("text", { fg: fg, children: `${prefix}${colorIndicator}${option.name}` }) }, option.value));
98
+ }) })] }));
99
+ }
@@ -7,6 +7,7 @@ import { createTask, updateTask, listTasks } from "../daemon.js";
7
7
  import { useHoverState } from "../hooks/useHoverState.js";
8
8
  import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
9
9
  import { HOVER_BG } from "../../config/constants.js";
10
+ import { SearchSelect } from "./SearchSelect.js";
10
11
  const taskFields = ["name", "command", "onSuccessTaskId", "onFailureTaskId"];
11
12
  function taskInitialValues(task) {
12
13
  if (!task) {
@@ -57,6 +58,9 @@ export function TaskForm(props) {
57
58
  return;
58
59
  }
59
60
  if (key.name === "up" || key.name === "down") {
61
+ const field = taskFields[focusIndex];
62
+ if (field === "onSuccessTaskId" || field === "onFailureTaskId")
63
+ return;
60
64
  setFocusIndex((i) => {
61
65
  const next = key.name === "up" ? i - 1 : i + 1;
62
66
  if (next < 0)
@@ -155,8 +159,7 @@ function TaskFormRow(props) {
155
159
  const isFocused = focusIndex === index;
156
160
  const isSelect = field === "onSuccessTaskId" || field === "onFailureTaskId";
157
161
  const selectOpts = isSelect ? chainOptions : [];
158
- const selectedIdx = isSelect ? selectOpts.findIndex((o) => o.value === values[field]) : 0;
159
- return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: Math.min(selectOpts.length + 2, 6), backgroundColor: "#0b0b0b" }, children: _jsx("select", { focused: isFocused, options: selectOpts, selectedIndex: Math.max(0, selectedIdx), showDescription: false, style: { flexGrow: 1 }, onChange: (_i, option) => updateValues({ ...valuesRef.current, [field]: option?.value ?? "" }) }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
162
+ return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx(SearchSelect, { options: selectOpts, value: values[field], onChange: (v) => updateValues({ ...valuesRef.current, [field]: v }), focused: isFocused })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
160
163
  if (index < taskFields.length - 1) {
161
164
  setFocusIndex(index + 1);
162
165
  }
@@ -1,7 +1,7 @@
1
1
  import { useKeyboard } from "@opentui/react";
2
2
  import { nextTaskPanel } from "../components/TaskBrowser.js";
3
3
  export function useTaskKeybindings(params) {
4
- const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, onEnterHeader, selectable = true, } = params;
4
+ const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, onEnterHeader, onToggleContextHelp, selectable = true, } = params;
5
5
  const taskActions = selectable
6
6
  ? ["select", "edit", "delete"]
7
7
  : ["edit", "delete"];
@@ -36,6 +36,11 @@ export function useTaskKeybindings(params) {
36
36
  key.preventDefault();
37
37
  return;
38
38
  }
39
+ if (name === "?") {
40
+ onToggleContextHelp();
41
+ key.preventDefault();
42
+ return;
43
+ }
39
44
  if (name === "n") {
40
45
  onCreateTask();
41
46
  key.preventDefault();
@@ -3,6 +3,7 @@ export const TOAST_TIMEOUT_MS = 3500;
3
3
  export const TOAST_MAX = 4;
4
4
  export const LOG_LINES_MAX = 500;
5
5
  export const MAX_LOG_BYTES = 1024 * 1024;
6
+ export const MAX_CONTEXT_STDOUT_BYTES = 1024 * 1024;
6
7
  export const MAX_LOG_GENERATIONS = 3;
7
8
  export const SLEEP_CHUNK_MS = 200;
8
9
  export const DAEMON_SPAWN_TIMEOUT_MS = 5000;
@@ -11,6 +12,7 @@ export const IPC_TIMEOUT_MS = 10000;
11
12
  export const LOG_TAIL_DEFAULT = 50;
12
13
  export const BOARD_BREAKPOINT_WIDTH = 80;
13
14
  export const HEADER_COMPACT_WIDTH = 60;
15
+ export const SEARCH_SELECT_HEIGHT = 6;
14
16
  export const HOVER_BG = "#1e3a5f";
15
17
  export const PROJECT_COLORS = {
16
18
  white: "#ffffff",
@@ -2,12 +2,13 @@ import fs from "node:fs";
2
2
  import { execa } from "execa";
3
3
  import { formatDuration } from "../duration.js";
4
4
  import { t } from "../i18n/index.js";
5
+ import { MAX_CONTEXT_STDOUT_BYTES } from "../config/constants.js";
5
6
  export function extractExitCode(error) {
6
7
  return error && typeof error === "object" && "exitCode" in error
7
8
  ? error.exitCode
8
9
  : 1;
9
10
  }
10
- export async function executeCommand(command, commandArgs, cwd, logStream, signal, runNumber) {
11
+ export async function executeCommand(command, commandArgs, cwd, logStream, signal, runNumber, captureStdout = false) {
11
12
  const startedAt = new Date();
12
13
  const header = t("loop.runHeader", { timestamp: startedAt.toLocaleString(), runNumber: runNumber ?? 0 });
13
14
  logStream.write(header);
@@ -25,8 +26,25 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
25
26
  cancelSignal: signal,
26
27
  shell: true,
27
28
  });
29
+ const stdoutChunks = [];
30
+ let stdoutTruncated = false;
31
+ let stdoutBytes = 0;
28
32
  child.stdout.on("data", (chunk) => {
29
33
  logStream.write(chunk);
34
+ if (captureStdout && !stdoutTruncated) {
35
+ const chunkStr = chunk.toString();
36
+ const chunkLen = Buffer.byteLength(chunkStr, "utf-8");
37
+ if (stdoutBytes + chunkLen > MAX_CONTEXT_STDOUT_BYTES) {
38
+ const remaining = MAX_CONTEXT_STDOUT_BYTES - stdoutBytes;
39
+ stdoutChunks.push(chunkStr.slice(0, remaining));
40
+ stdoutTruncated = true;
41
+ logStream.write(t("context.truncationWarning"));
42
+ }
43
+ else {
44
+ stdoutChunks.push(chunkStr);
45
+ stdoutBytes += chunkLen;
46
+ }
47
+ }
30
48
  });
31
49
  child.stderr.on("data", (chunk) => {
32
50
  logStream.write(chunk);
@@ -36,14 +54,26 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
36
54
  const endedAt = new Date();
37
55
  const duration = endedAt.getTime() - startedAt.getTime();
38
56
  logStream.write(t("loop.exitMarker", { code: String(result.exitCode), duration: formatDuration(duration) }));
39
- return { exitCode: result.exitCode ?? 0, duration, startedAt, endedAt };
57
+ return {
58
+ exitCode: result.exitCode ?? 0,
59
+ duration,
60
+ startedAt,
61
+ endedAt,
62
+ ...(captureStdout ? { stdout: stdoutChunks.join("") } : {}),
63
+ };
40
64
  }
41
65
  catch (error) {
42
66
  const endedAt = new Date();
43
67
  const duration = endedAt.getTime() - startedAt.getTime();
44
68
  const exitCode = extractExitCode(error);
45
69
  logStream.write(t("loop.exitMarker", { code: exitCode, duration: formatDuration(duration) }));
46
- return { exitCode, duration, startedAt, endedAt };
70
+ return {
71
+ exitCode,
72
+ duration,
73
+ startedAt,
74
+ endedAt,
75
+ ...(captureStdout ? { stdout: stdoutChunks.join("") } : {}),
76
+ };
47
77
  }
48
78
  }
49
79
  export async function executeCommandForeground(command, commandArgs, logger, cwd = "") {
@@ -0,0 +1,55 @@
1
+ function isObject(value) {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
4
+ export function parseStdout(raw) {
5
+ const trimmed = raw.trim();
6
+ if (trimmed.length === 0) {
7
+ return null;
8
+ }
9
+ let whole;
10
+ try {
11
+ whole = JSON.parse(trimmed);
12
+ }
13
+ catch {
14
+ whole = undefined;
15
+ }
16
+ if (whole !== undefined) {
17
+ if (isObject(whole)) {
18
+ return whole;
19
+ }
20
+ if (typeof whole === "string" || typeof whole === "number" || typeof whole === "boolean" || whole === null) {
21
+ return { output: String(whole) };
22
+ }
23
+ if (Array.isArray(whole)) {
24
+ return { output: trimmed };
25
+ }
26
+ }
27
+ const lines = trimmed.split("\n");
28
+ const parsedLines = [];
29
+ let jsonlSuccess = true;
30
+ for (const line of lines) {
31
+ const l = line.trim();
32
+ if (l.length === 0)
33
+ continue;
34
+ try {
35
+ parsedLines.push(JSON.parse(l));
36
+ }
37
+ catch {
38
+ jsonlSuccess = false;
39
+ break;
40
+ }
41
+ }
42
+ if (jsonlSuccess && parsedLines.length > 0) {
43
+ const result = {};
44
+ for (const pl of parsedLines) {
45
+ if (isObject(pl)) {
46
+ Object.assign(result, pl);
47
+ }
48
+ else {
49
+ result.output = String(pl);
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+ return { output: trimmed };
55
+ }
@@ -7,6 +7,8 @@ import { executeCommand } from "./command-runner.js";
7
7
  import { rotateLogIfNeeded } from "./log-rotator.js";
8
8
  import { computePhase, alignToPhase } from "./scheduling.js";
9
9
  import { t } from "../i18n/index.js";
10
+ import { parseStdout } from "./context-parser.js";
11
+ import { interpolate } from "./template.js";
10
12
  export class LoopController extends EventEmitter {
11
13
  abortController;
12
14
  runAbortController = null;
@@ -323,8 +325,16 @@ export class LoopController extends EventEmitter {
323
325
  const command = task?.command ?? this.options.command;
324
326
  const commandArgs = task?.commandArgs ?? this.options.commandArgs;
325
327
  const cwd = this.options.cwd;
326
- const result = await executeCommand(command, commandArgs, cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount);
328
+ const chainContext = {};
329
+ const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
330
+ const result = await executeCommand(command, commandArgs, cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount, hasChainTasks);
327
331
  this.runAbortController = null;
332
+ if (hasChainTasks && result.stdout) {
333
+ const parsed = parseStdout(result.stdout);
334
+ if (parsed !== null) {
335
+ Object.assign(chainContext, parsed);
336
+ }
337
+ }
328
338
  this.lastExitCode = result.exitCode;
329
339
  this.lastDuration = result.duration;
330
340
  const logSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - this.currentRunStartOffset : 0;
@@ -373,7 +383,15 @@ export class LoopController extends EventEmitter {
373
383
  chainGroupId,
374
384
  chainName: chainTask.name,
375
385
  });
376
- const chainResult = await executeCommand(chainTask.command, chainTask.commandArgs, this.options.cwd, this.logStream, signal, this.runCount);
386
+ const interpolatedCommand = interpolate(chainTask.command, chainContext);
387
+ const interpolatedArgs = chainTask.commandArgs.map(a => interpolate(a, chainContext));
388
+ const chainResult = await executeCommand(interpolatedCommand, interpolatedArgs, this.options.cwd, this.logStream, signal, this.runCount, true);
389
+ if (chainResult.stdout) {
390
+ const parsed = parseStdout(chainResult.stdout);
391
+ if (parsed !== null) {
392
+ Object.assign(chainContext, parsed);
393
+ }
394
+ }
377
395
  const chainLogSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - chainOffset : 0;
378
396
  const chainRecord = this.runHistory.find((r) => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
379
397
  if (chainRecord) {
@@ -0,0 +1,3 @@
1
+ export function interpolate(input, context) {
2
+ return input.replace(/{{(\w+)}}/g, (_, key) => String(context[key] ?? ""));
3
+ }
package/dist/i18n/en.json CHANGED
@@ -128,6 +128,7 @@
128
128
  "board.idleLabel": " Stopped: ",
129
129
  "board.searchTitle": " Search / ",
130
130
  "board.searchPlaceholder": "type to filter, enter to apply",
131
+ "board.searchSelectPlaceholder": "type to filter...",
131
132
  "board.searchEmpty": "press / to search",
132
133
  "board.statusFilterTitle": " Status x ",
133
134
  "board.sortTitle": " Order o ",
@@ -384,5 +385,11 @@
384
385
  "project.error.deleteFailed": "Failed to delete project",
385
386
  "project.toastCreated": "Project \"{name}\" created",
386
387
  "project.toastUpdated": "Project \"{name}\" updated",
387
- "project.toastDeleted": "Project \"{name}\" deleted"
388
+ "project.toastDeleted": "Project \"{name}\" deleted",
389
+ "context.truncationWarning": "stdout exceeded 1MB - context was truncated",
390
+ "context.helpTitle": "Chain Context Sharing",
391
+ "context.helpRules": "JSON output merges its keys. JSONL merges each line as a separate key. Plain text is stored under the \"output\" key. Empty output produces no change.",
392
+ "context.helpTemplates": "Use {{key}} in commands and args - replaced with context values from earlier tasks in the chain.",
393
+ "context.helpCaveat": "Plain text stdout overwrites the \"output\" key. Use JSON with named keys when data must survive across tasks.",
394
+ "context.helpJqTip": "Tip: use --jq '{key: .field}' to wrap any value in a named JSON key"
388
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "1.4.7",
3
+ "version": "1.5.0",
4
4
  "description": "Loop engineering toolkit. Run any command on a cadence, in the background, managed from a terminal board. Schedule tests, builds, syncs, or agent prompts.",
5
5
  "type": "module",
6
6
  "bin": {