@plainconceptsplatform/loop-task 2.6.0 → 2.8.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Quique Fdez Guerra
3
+ Copyright (c) 2026 Plain Concepts
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -5,14 +5,14 @@
5
5
 
6
6
  **Loop engineering for your terminal. Run any command on a cadence.**
7
7
 
8
- `@plainconceptsplatform/loop-task` is a cross-platform CLI that runs shell commands at human-readable intervals. Create loops in the background, manage them from an interactive TUI board, or run them in the foreground. It is the **heartbeat** primitive for [loop engineering](#loop-engineering): instead of running a task by hand every time, you schedule it once and let it run.
8
+ `loop-task` is a cross-platform CLI that runs shell commands at human-readable intervals. Create loops in the background, manage them from an interactive TUI board, or run them in the foreground. It is the **heartbeat** primitive for [loop engineering](#loop-engineering): instead of running a task by hand every time, you schedule it once and let it run.
9
9
 
10
- **[loop.ckgrafico.com](https://loop.ckgrafico.com)**
10
+ **[plainconceptsplatform.github.io/loop-task](https://plainconceptsplatform.github.io/loop-task)**
11
11
 
12
- [![npm version](https://img.shields.io/npm/v/@plainconceptsplatform/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/@plainconceptsplatform/loop-task)
13
- [![npm downloads](https://img.shields.io/npm/dm/@plainconceptsplatform/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/@plainconceptsplatform/loop-task)
14
- [![license](https://img.shields.io/npm/l/@plainconceptsplatform/loop-task?style=flat-square&color=black)](./LICENSE)
15
- [![node](https://img.shields.io/node/v/@plainconceptsplatform/loop-task?style=flat-square&color=black)](https://nodejs.org)
12
+ [![npm version](https://img.shields.io/npm/v/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/loop-task)
13
+ [![npm downloads](https://img.shields.io/npm/dm/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/loop-task)
14
+ [![license](https://img.shields.io/npm/l/loop-task?style=flat-square&color=black)](./LICENSE)
15
+ [![node](https://img.shields.io/node/v/loop-task?style=flat-square&color=black)](https://nodejs.org)
16
16
 
17
17
  </div>
18
18
 
@@ -23,7 +23,7 @@
23
23
  `loop-task` is that heartbeat as a tiny local primitive. Some examples:
24
24
 
25
25
  <div align="center">
26
- <img src="https://raw.githubusercontent.com/plainconceptsplatform/loop-task/refs/heads/main/demo.gif" alt="loop-task demo" width="700" />
26
+ <img src="https://raw.githubusercontent.com/PlainConceptsPlatform/loop-task/refs/heads/main/demo.gif" alt="loop-task demo" width="700" />
27
27
  </div>
28
28
 
29
29
  ```bash
@@ -47,7 +47,7 @@ No cron files to maintain and no daemon to babysit: loops persist across reboots
47
47
  ## Quick start
48
48
 
49
49
  ```bash
50
- npm install -g @plainconceptsplatform/loop-task
50
+ npm install -g loop-task
51
51
  loop-task # open the board
52
52
  loop-task start # start the daemon, restore persisted loops
53
53
  loop-task new 30m -- npm test # create a background loop
@@ -60,28 +60,28 @@ loop-task api # show HTTP API endpoints (Swagger, OpenAPI)
60
60
  Or run it directly:
61
61
 
62
62
  ```bash
63
- npx @plainconceptsplatform/loop-task
64
- npx @plainconceptsplatform/loop-task new 30m -- npm test
65
- ```
66
-
67
- ## Agent skills
68
-
69
- Install the public Loop Task skills with:
70
-
71
- ```bash
72
- npx skills add plainconceptsplatform/loop-task
73
- ```
74
-
75
- The repository also contains internal development skills under `.agents/skills/`.
76
- They are marked internal automatically by the repository pre-commit hook and are
77
- not intended for installation by Loop Task users.
78
-
79
- ## Requirements
63
+ npx loop-task
64
+ npx loop-task new 30m -- npm test
65
+ ```
66
+
67
+ ## Agent skills
68
+
69
+ Install the public Loop Task skills with:
70
+
71
+ ```bash
72
+ npx skills add PlainConceptsPlatform/loop-task
73
+ ```
74
+
75
+ The repository also contains internal development skills under `.agents/skills/`.
76
+ They are marked internal automatically by the repository pre-commit hook and are
77
+ not intended for installation by Loop Task users.
78
+
79
+ ## Requirements
80
80
 
81
81
  - **Node.js >= 20** - required for all commands including the board
82
82
 
83
83
  ```bash
84
- npm install -g @plainconceptsplatform/loop-task
84
+ npm install -g loop-task
85
85
  ```
86
86
 
87
87
  All commands (`start`, `new`, `run`, `board`) work with Node alone.
@@ -284,15 +284,15 @@ When tasks are arranged in a chain (on-success or on-failure), context flows bet
284
284
 
285
285
  ### How it works
286
286
 
287
- 1. **Auto-capture** - stdout and stderr from every task in the chain are captured before the next task starts.
288
- 2. **Parse rules** - stdout is parsed by content type:
289
- - **JSON object** (`{"key": "value"}`) - each key is merged into the shared context.
287
+ 1. **Auto-capture** - stdout and stderr from every task in the chain are captured before the next task starts.
288
+ 2. **Parse rules** - stdout is parsed by content type:
289
+ - **JSON object** (`{"key": "value"}`) - each key is merged into the shared context.
290
290
  - **JSONL** (one JSON object per line) - each line's keys are merged in order.
291
- - **Plain text** - stored under a single `output` key.
291
+ - **Plain text** - stored under a single `output` key.
292
292
  - **Empty output** - no change to context.
293
293
  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.
294
294
  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.
295
- 5. **Latest command output** - `{{output}}` always contains stdout plus stderr from the immediately preceding task. JSON fields such as `{{number}}`, `{{title}}`, and `{{body}}` remain available until a later task explicitly emits the same key.
295
+ 5. **Latest command output** - `{{output}}` always contains stdout plus stderr from the immediately preceding task. JSON fields such as `{{number}}`, `{{title}}`, and `{{body}}` remain available until a later task explicitly emits the same key.
296
296
  6. **Context lifecycle** - context is built fresh each loop iteration and exists only in memory. It is never persisted to disk.
297
297
 
298
298
  ### Example: Issue Refinement Chain
package/dist/app/App.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useCallback, useEffect } from "react";
2
+ import React, { useMemo, useCallback, useEffect } from "react";
3
3
  import { Box, useApp } from "ink";
4
4
  import { useLoopPolling } from "../shared/hooks/useLoopPolling.js";
5
5
  import { useDaemonSettings } from "../shared/hooks/useDaemonSettings.js";
@@ -25,6 +25,7 @@ import { useOverlayStack } from "../features/overlays/useOverlayStack.js";
25
25
  import { useGlobalShortcuts } from "../features/commands/useGlobalShortcuts.js";
26
26
  import { FormRouter } from "../features/forms/FormRouter.js";
27
27
  import { OverlayStack } from "../features/overlays/OverlayStack.js";
28
+ import { Splash } from "./Splash.js";
28
29
  import { MOUSE_TRACKING_ENABLE } from "../shared/config/constants.js";
29
30
  function viewKey(view, editTarget, editTask) {
30
31
  if (view === "create")
@@ -126,6 +127,9 @@ export function App(props) {
126
127
  pushToast("success", updated ? t("project.toastUpdated", { name }) : t("project.toastCreated", { name }));
127
128
  };
128
129
  const commandContext = useMemo(() => ({ activeTab: s.activeTab, selectedLoop: s.selected, selectedTask: s.selectedTask, selectedProject: s.selectedProjectEntity }), [s.activeTab, s.selected, s.selectedTask]);
130
+ if (daemonStatus === "starting") {
131
+ return React.createElement(Splash);
132
+ }
129
133
  return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, httpApiEnabled: daemonSettings.reachable ? daemonSettings.httpApiEnabled : undefined, mcpApiEnabled: daemonSettings.reachable ? daemonSettings.mcpApiEnabled : undefined, telemetryEnabled: daemonSettings.reachable ? daemonSettings.telemetryEnabled : undefined, counts: s.counts, activeTab: s.activeTab, onTabChange: s.setActiveTab, tabCounts: s.tabCounts }), _jsx(Box, { flexGrow: 1, width: "100%", children: isBoardView(view) ? (_jsxs(BoardLayout, { breakpoint: breakpoint, children: [_jsx(LeftPanel, { isFocused: (breakpoint === "minimal" || s.focusedPanel === "left") && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, query: s.leftPanelQuery, loops: s.visible, selectedIndex: s.clampedIndex, filters: s.filters, sort: s.sort, breakpoint: breakpoint, projects: s.filteredProjects, onSelect: (i) => s.setSelectedIndex(i), onActivate: (i) => { s.setSelectedIndex(i); }, tasks: s.filteredTasks, taskSelectedIndex: s.taskClampedIndex, onTaskSelect: (i) => s.setTaskSelectedIndex(i), onTaskActivate: (i) => { s.setTaskSelectedIndex(i); s.setEditTask(s.filteredTasks[i] ?? null); push("task-edit"); }, onStatusCycle: () => s.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => s.setSort(cycleSortMode(s.sort)), onSelectProject: () => s.setActiveTab("projects"), currentProjectName: s.currentProjectId === "all" ? t("project.showAll") : (s.projects.find(p => p.id === s.currentProjectId)?.name ?? "Default"), projectFilters: s.projectFilters, projectSelectedIndex: s.projectClampedIndex, onProjectSelect: (i) => s.setProjectSelectedIndex(i), onProjectActivate: (i) => { s.setProjectSelectedIndex(i); }, projectLoops: loops }), breakpoint !== "minimal" ? (_jsx(RightPanel, { breakpoint: breakpoint, isFocused: s.focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, loop: s.selected, selectedRunIndex: s.selectedRunIndex, onSelectRun: (i) => s.setSelectedRunIndex(i), onOpenRun: s.handleOpenRunLog, selectedTask: s.selectedTask, allTasks: s.tasks, selectedProject: s.selectedProjectEntity, projectLoopCount: s.projectLoopCount, projects: s.projects, onProjectEdit: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
130
134
  handleCommand("edit"); }, onProjectDelete: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
131
135
  handleCommand("delete"); } })) : null, s.debugMode ? _jsx(DebugPanel, { breakpoint: breakpoint, entries: s.debugEntries }) : null] })) : (_jsx(FormRouter, { view: view, editTarget: s.editTarget, cloneMode: s.cloneMode, editTask: s.editTask, editProject: s.editProject, pendingTaskSelection: s.pendingTaskSelection, tasks: s.tasks, projects: s.projects, currentProjectId: s.currentProjectId, cancelCreate: cancelCreate, onCreateDone: onCreateDone, handleChooseTask: handleChooseTask, cancelTask: cancelTask, onTaskDone: onTaskDone, cancelProject: cancelProject, onProjectDone: onProjectDone })) }, viewKey(view, s.editTarget, s.editTask)), isBoardView(view) ? (_jsx(CommandInput, { context: commandContext, onCommand: handleCommand, confirmState: s.confirmState, searchState: s.searchState, searchValue: s.searchValue, onSearchChange: s.handleSearchChange, onSearchSubmit: s.handleSearchSubmit, onSearchCancel: s.handleSearchCancel, onConfirmYes: handleConfirmYes, onConfirmCancel: handleConfirmCancel, onCopy: handleContextualCopy, onPanelAction: triggerContextualAction, disabled: commandInputDisabled, navOwner: inputOwner, onInputStateChange: (hasText, dropdownOpen) => { s.setCommandBarHasText(hasText); s.setCommandBarDropdownOpen(dropdownOpen); } })) : null, _jsx(OverlayStack, { commandsBrowserOpen: s.commandsBrowserOpen, commandContext: commandContext, onCommandsBrowserClose: () => s.setCommandsBrowserOpen(false), onCommandsBrowserExecute: (v) => { s.setCommandsBrowserOpen(false); handleCommand(v); }, contextHelpOpen: s.contextHelpOpen, onContextHelpClose: () => s.setContextHelpOpen(false), exportModal: s.exportModal, onExportModalClose: () => s.setExportModal(null), onExportCopy: () => pushToast("success", t("board.toastCopied")), diagramModal: s.diagramModal, onDiagramModalClose: () => s.setDiagramModal(null), logModalRun: s.logModalRun, logModalLoopId: s.logModalLoopId, logModalLines: s.logModalLines, logModalLoading: s.logModalLoading, onLogModalClose: () => { s.setLogModalRun(null); s.setLogModalLoopId(null); }, onLogCopy: () => pushToast("success", t("board.toastCopied")), toasts: toasts })] }));
@@ -0,0 +1,19 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Text } from "ink";
3
+ const LOGO = [
4
+ " _ _ _ ",
5
+ "| | ___ ___ _ __ | |_ __ _ ___| | __",
6
+ "| |/ _ \\ / _ \\| '_ \\____| __/ _` / __| |/ /",
7
+ "| | (_) | (_) | |_) |____| || (_| \\__ \\ < ",
8
+ "|_|\\___/ \\___/| .__/ \\__\\__,_|___/_|\\_\\",
9
+ " |_| ",
10
+ ];
11
+ const DOTS = [". ", ".. ", "...", " ..", " .", " . "];
12
+ export function Splash() {
13
+ const [frame, setFrame] = useState(0);
14
+ useEffect(() => {
15
+ const timer = setInterval(() => setFrame((f) => (f + 1) % DOTS.length), 200);
16
+ return () => clearInterval(timer);
17
+ }, []);
18
+ return React.createElement(React.Fragment, null, ...LOGO.map((line, i) => React.createElement(Text, { key: i, color: "cyan", bold: true }, line)), React.createElement(Text, { color: "gray" }, `Loading${DOTS[frame]}`));
19
+ }
@@ -2,8 +2,15 @@ import net from "node:net";
2
2
  import { ensureDaemon, getSocket } from "../daemon/spawner/index.js";
3
3
  import { t } from "../shared/i18n/index.js";
4
4
  import { IPC_TIMEOUT_MS } from "../shared/config/constants.js";
5
- export async function sendRequest(request) {
5
+ let daemonEnsured = false;
6
+ export async function ensureDaemonReady() {
7
+ if (daemonEnsured)
8
+ return;
6
9
  ensureDaemon();
10
+ daemonEnsured = true;
11
+ }
12
+ export async function sendRequest(request) {
13
+ await ensureDaemonReady();
7
14
  const socketPath = getSocket();
8
15
  return new Promise((resolve, reject) => {
9
16
  let resolved = false;
@@ -74,7 +74,7 @@ export async function createProjectCli(name, colorInput, directory, githubSource
74
74
  try {
75
75
  if (githubSource !== undefined && githubSource !== "") {
76
76
  if (!/^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/.test(githubSource)) {
77
- throw new Error(`Invalid github-source format: "${githubSource}". Expected owner/repo (e.g. CKGrafico/loop-task)`);
77
+ throw new Error(`Invalid github-source format: "${githubSource}". Expected owner/repo (e.g. PlainConceptsPlatform/loop-task)`);
78
78
  }
79
79
  }
80
80
  const color = colorInput ? resolveColor(colorInput) : PROJECT_COLORS.cyan;
@@ -6,8 +6,9 @@ import { t } from "../../shared/i18n/index.js";
6
6
  import { MAX_CONTEXT_STDOUT_BYTES, MAX_SPAN_OUTPUT_BYTES } from "../../shared/config/constants.js";
7
7
  import { StdoutCaptureTransform } from "./stdout-capture-transform.js";
8
8
  import { killProcessTree } from "./process-tree.js";
9
- import { getAgentIntegrations } from "../../daemon/telemetry/agent-integrations/index.js";
9
+ import { getAgentIntegrations, detectAgentIntegration } from "../../daemon/telemetry/agent-integrations/index.js";
10
10
  import { getDataDir } from "../../shared/config/paths.js";
11
+ import { parseOpencodeJsonOutput } from "../context/opencode-json-parser.js";
11
12
  function quoteArg(arg) {
12
13
  if (arg.length === 0)
13
14
  return "''";
@@ -67,12 +68,13 @@ export function killAllActiveProcesses() {
67
68
  }
68
69
  }
69
70
  export async function executeCommand(command, commandArgs, cwd, logStream, signal, runNumber, captureStdout = false, isChain = false, telemetryCtx) {
71
+ let effectiveCommand = command;
72
+ let effectiveArgs = commandArgs;
70
73
  const startedAt = new Date();
71
74
  if (!isChain) {
72
75
  const header = t("loop.runHeader", { timestamp: startedAt.toLocaleString(), runNumber: runNumber ?? 0 });
73
76
  logStream.write(header);
74
77
  }
75
- logStream.write(t("loop.commandLine", { command: formatCommandLine(command, commandArgs) }));
76
78
  if (cwd) {
77
79
  logStream.write(t("loop.cwdLine", { cwd }));
78
80
  }
@@ -82,8 +84,6 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
82
84
  logStream.write(t("loop.exitMarker", { code: 1, duration: formatDuration(0) }));
83
85
  return { exitCode: 1, duration: 0, startedAt, endedAt };
84
86
  }
85
- const shellCommand = formatCommandLine(command, commandArgs);
86
- const needShell = /(\$\(|`|&&|\|\||;|>|<|\|)/.test(shellCommand);
87
87
  // Per-task telemetry override: skip telemetry for this command entirely
88
88
  const taskTelemetryDisabled = telemetryCtx?.telemetryConfig?.enabled === false;
89
89
  // Telemetry: create command span and prepare child env
@@ -121,6 +121,74 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
121
121
  commandSpan.setAttribute("loop_task.agent.integration", detectedIntegrationId);
122
122
  }
123
123
  }
124
+ if (!detectedIntegrationId) {
125
+ const integration = detectAgentIntegration(command, commandArgs);
126
+ if (integration) {
127
+ detectedIntegrationId = integration.id;
128
+ const prepared = integration.prepare({ command, args: commandArgs, cwd, env: {} }, {
129
+ runId: telemetryCtx?.runId ?? "",
130
+ loopId: telemetryCtx?.loopId ?? "",
131
+ loopName: telemetryCtx?.loopName ?? "",
132
+ });
133
+ telemetryEnv = { ...telemetryEnv, ...prepared.env };
134
+ }
135
+ }
136
+ if (detectedIntegrationId) {
137
+ const allIntegrations = getAgentIntegrations();
138
+ const integration = allIntegrations.find((i) => i.id === detectedIntegrationId);
139
+ if (integration?.ensureServe && integration?.isServeAlive && integration?.prepareRunArgs) {
140
+ try {
141
+ const serveEnv = {};
142
+ if (telemetryEnv.OTEL_EXPORTER_OTLP_ENDPOINT) {
143
+ serveEnv.OTEL_EXPORTER_OTLP_ENDPOINT = telemetryEnv.OTEL_EXPORTER_OTLP_ENDPOINT;
144
+ serveEnv.OTEL_EXPORTER_OTLP_PROTOCOL = telemetryEnv.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
145
+ serveEnv.OTEL_TRACES_EXPORTER = "otlp";
146
+ serveEnv.OTEL_METRICS_EXPORTER = "otlp";
147
+ serveEnv.OTEL_LOGS_EXPORTER = "otlp";
148
+ if (telemetryEnv.OTEL_RESOURCE_ATTRIBUTES) {
149
+ serveEnv.OTEL_RESOURCE_ATTRIBUTES = telemetryEnv.OTEL_RESOURCE_ATTRIBUTES;
150
+ }
151
+ if (telemetryEnv.OPENCODE_EXPERIMENTAL_OPEN_TELEMETRY) {
152
+ serveEnv.OPENCODE_EXPERIMENTAL_OPEN_TELEMETRY = telemetryEnv.OPENCODE_EXPERIMENTAL_OPEN_TELEMETRY;
153
+ }
154
+ }
155
+ await integration.ensureServe(cwd || process.cwd(), serveEnv);
156
+ if (integration.isServeAlive()) {
157
+ effectiveArgs = integration.prepareRunArgs(commandArgs, cwd || undefined);
158
+ if (commandSpan) {
159
+ commandSpan.setAttribute("loop_task.agent.serve_attached", true);
160
+ }
161
+ }
162
+ }
163
+ catch (err) {
164
+ // Serve failure is non-fatal — fall back to cold start
165
+ if (commandSpan) {
166
+ commandSpan.setAttribute("loop_task.agent.serve_error", String(err));
167
+ }
168
+ }
169
+ }
170
+ }
171
+ if (detectedIntegrationId === "opencode" && effectiveArgs[0] === "run") {
172
+ if (!effectiveArgs.includes("--format") && !effectiveArgs.includes("-f")) {
173
+ effectiveArgs = [...effectiveArgs.slice(0, 1), "--format", "json", ...effectiveArgs.slice(1)];
174
+ }
175
+ if (!effectiveArgs.includes("--session") && !effectiveArgs.includes("-s")) {
176
+ const opencodeCtx = telemetryCtx?.chainContext?.opencode;
177
+ const sessionId = opencodeCtx?.session?.id;
178
+ if (sessionId) {
179
+ effectiveArgs = [...effectiveArgs.slice(0, 1), "--session", sessionId, ...effectiveArgs.slice(1)];
180
+ if (!effectiveArgs.includes("--model") && !effectiveArgs.includes("-m")) {
181
+ const model = opencodeCtx?.model;
182
+ if (model) {
183
+ effectiveArgs = [...effectiveArgs.slice(0, 1), "--model", model, ...effectiveArgs.slice(1)];
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+ logStream.write(t("loop.commandLine", { command: formatCommandLine(effectiveCommand, effectiveArgs) }));
190
+ const shellCommand = formatCommandLine(effectiveCommand, effectiveArgs);
191
+ const needShell = /(\$\(|`|&&|\|\||;|>|<|\|)/.test(shellCommand);
124
192
  const baseEnv = childEnv();
125
193
  const fileEnv = loadEnvFile();
126
194
  const mergedEnv = {
@@ -142,7 +210,7 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
142
210
  killSignal: "SIGTERM",
143
211
  ...detachedOpt,
144
212
  })
145
- : execa(command, commandArgs, {
213
+ : execa(effectiveCommand, effectiveArgs, {
146
214
  stdout: "pipe",
147
215
  stderr: "pipe",
148
216
  stdin: "ignore",
@@ -156,8 +224,9 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
156
224
  if (child.pid) {
157
225
  activePids.add(child.pid);
158
226
  }
227
+ const isOpencodeJson = detectedIntegrationId === "opencode" && effectiveArgs.includes("--format") && effectiveArgs.includes("json");
159
228
  const stdoutCapture = (captureStdout || !!detectedIntegrationId)
160
- ? new StdoutCaptureTransform(MAX_CONTEXT_STDOUT_BYTES)
229
+ ? new StdoutCaptureTransform(MAX_CONTEXT_STDOUT_BYTES, isOpencodeJson)
161
230
  : null;
162
231
  const stderrCapture = (captureStdout || !!detectedIntegrationId)
163
232
  ? new StdoutCaptureTransform(MAX_CONTEXT_STDOUT_BYTES)
@@ -190,10 +259,18 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
190
259
  commandSpan.setAttribute("loop_task.command.stdout", truncateForSpan(stdoutCapture.getCaptured(), MAX_SPAN_OUTPUT_BYTES));
191
260
  }
192
261
  }
193
- // Attempt to parse agent usage from output
194
262
  if (detectedIntegrationId && stdoutCapture) {
195
263
  tryParseAgentUsage(telemetryCtx.telemetry, detectedIntegrationId, { exitCode: result.exitCode ?? 0, stdout: stdoutCapture.getCaptured(), duration }, commandSpan);
196
264
  }
265
+ let opencodeResult = undefined;
266
+ const opencodeCtx = (detectedIntegrationId === "opencode" && stdoutCapture)
267
+ ? parseOpencodeJsonOutput(stdoutCapture.getCaptured())
268
+ : null;
269
+ if (opencodeCtx) {
270
+ opencodeCtx.model = extractModelFromArgs(effectiveArgs);
271
+ opencodeResult = opencodeCtx;
272
+ writeOpencodeSummary(logStream, opencodeCtx, isOpencodeJson);
273
+ }
197
274
  if (commandSpan) {
198
275
  commandSpan.ok();
199
276
  }
@@ -204,6 +281,7 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
204
281
  endedAt,
205
282
  ...(captureStdout && stdoutCapture ? { stdout: stdoutCapture.getCaptured() } : {}),
206
283
  ...(captureStdout && stderrCapture ? { stderr: stderrCapture.getCaptured() } : {}),
284
+ ...(opencodeResult ? { opencode: opencodeResult } : {}),
207
285
  };
208
286
  }
209
287
  catch (error) {
@@ -298,3 +376,22 @@ function truncateForSpan(text, maxBytes) {
298
376
  return text;
299
377
  return buf.subarray(0, maxBytes).toString("utf-8") + `\n... [truncated, ${buf.length} bytes total]`;
300
378
  }
379
+ function extractModelFromArgs(args) {
380
+ const flags = ["--model", "-m"];
381
+ for (const flag of flags) {
382
+ const idx = args.indexOf(flag);
383
+ if (idx >= 0 && idx + 1 < args.length) {
384
+ return args[idx + 1];
385
+ }
386
+ }
387
+ return null;
388
+ }
389
+ function writeOpencodeSummary(logStream, ctx, writeSummary) {
390
+ if (!writeSummary)
391
+ return;
392
+ logStream.write(`session: ${ctx.session.id} | tokens: ${ctx.tokens.input} in / ${ctx.tokens.output} out | cost: $${ctx.cost} | tools: ${ctx.tools.count}\n`);
393
+ if (ctx.text)
394
+ logStream.write(`${ctx.text}\n`);
395
+ if (ctx.error)
396
+ logStream.write(`error: ${ctx.error.name}: ${ctx.error.message}\n`);
397
+ }
@@ -2,28 +2,64 @@ import { Transform } from "node:stream";
2
2
  import { MAX_CONTEXT_STDOUT_BYTES } from "../../shared/config/constants.js";
3
3
  const KV_LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
4
4
  export class StdoutCaptureTransform extends Transform {
5
- constructor(maxBytes = MAX_CONTEXT_STDOUT_BYTES) {
5
+ constructor(maxBytes = MAX_CONTEXT_STDOUT_BYTES, suppressJsonl = false) {
6
6
  super({ decodeStrings: true });
7
7
  this.captured = [];
8
8
  this.totalBytes = 0;
9
9
  this.truncated = false;
10
10
  this.partialLine = "";
11
11
  this.kvPairs = {};
12
+ this.suppressJsonl = false;
13
+ this.jsonBuffer = "";
12
14
  this.maxBytes = maxBytes;
15
+ this.suppressJsonl = suppressJsonl;
13
16
  }
14
17
  _transform(chunk, _encoding, callback) {
18
+ if (this.suppressJsonl) {
19
+ this.handleJsonlSuppression(chunk, callback);
20
+ }
21
+ else {
22
+ this.handleNormal(chunk, callback);
23
+ }
24
+ }
25
+ handleNormal(chunk, callback) {
15
26
  this.push(chunk);
16
- if (this.truncated) {
17
- callback();
18
- return;
27
+ this.captureBytes(chunk);
28
+ this.extractKvFromChunk(chunk);
29
+ callback();
30
+ }
31
+ handleJsonlSuppression(chunk, callback) {
32
+ this.captureBytes(chunk);
33
+ const text = chunk.toString("utf-8");
34
+ const combined = this.jsonBuffer + text;
35
+ const lines = combined.split("\n");
36
+ this.jsonBuffer = lines.pop() ?? "";
37
+ for (const line of lines) {
38
+ const trimmed = line.trim();
39
+ if (!trimmed)
40
+ continue;
41
+ try {
42
+ const parsed = JSON.parse(trimmed);
43
+ if (typeof parsed === "object" && parsed !== null && "type" in parsed) {
44
+ continue;
45
+ }
46
+ }
47
+ catch {
48
+ // not JSON, pass through
49
+ }
50
+ this.push(line + "\n");
19
51
  }
20
- const chunkBytes = chunk.byteLength;
52
+ callback();
53
+ }
54
+ captureBytes(chunk) {
55
+ if (this.truncated)
56
+ return;
21
57
  const remaining = this.maxBytes - this.totalBytes;
22
58
  if (remaining <= 0) {
23
59
  this.truncated = true;
24
- callback();
25
60
  return;
26
61
  }
62
+ const chunkBytes = chunk.byteLength;
27
63
  if (chunkBytes <= remaining) {
28
64
  this.captured.push(Buffer.from(chunk));
29
65
  this.totalBytes += chunkBytes;
@@ -35,7 +71,6 @@ export class StdoutCaptureTransform extends Transform {
35
71
  this.truncated = true;
36
72
  }
37
73
  this.extractKvFromChunk(chunk);
38
- callback();
39
74
  }
40
75
  extractKvFromChunk(chunk) {
41
76
  const text = chunk.toString("utf-8");
@@ -57,6 +92,21 @@ export class StdoutCaptureTransform extends Transform {
57
92
  }
58
93
  this.partialLine = "";
59
94
  }
95
+ if (this.jsonBuffer) {
96
+ const trimmed = this.jsonBuffer.trim();
97
+ if (trimmed) {
98
+ try {
99
+ const parsed = JSON.parse(trimmed);
100
+ if (!(typeof parsed === "object" && parsed !== null && "type" in parsed)) {
101
+ this.push(this.jsonBuffer + "\n");
102
+ }
103
+ }
104
+ catch {
105
+ this.push(this.jsonBuffer + "\n");
106
+ }
107
+ }
108
+ this.jsonBuffer = "";
109
+ }
60
110
  callback();
61
111
  }
62
112
  getCaptured() {
@@ -53,7 +53,29 @@ export function parseStdout(raw) {
53
53
  }
54
54
  return { output: trimmed };
55
55
  }
56
- export function mergeCommandOutput(context, stdout, stderr) {
56
+ export function mergeCommandOutput(context, stdout, stderr, opencode) {
57
+ if (opencode && typeof opencode === "object") {
58
+ const incoming = opencode;
59
+ const existing = context.opencode;
60
+ if (existing && typeof existing === "object" && existing.session && incoming.session && existing.session.id === incoming.session.id) {
61
+ context.opencode = accumulateOpencodeContext(existing, incoming);
62
+ }
63
+ else {
64
+ context.opencode = opencode;
65
+ }
66
+ if (typeof incoming.text === "string" && incoming.text.length > 0) {
67
+ context.output = incoming.text;
68
+ }
69
+ else {
70
+ const trimmed = stdout.trim();
71
+ if (trimmed.length > 0)
72
+ context.output = trimmed;
73
+ }
74
+ if (stderr.trim().length > 0) {
75
+ context.output = (context.output ? context.output + "\n" : "") + stderr.trim();
76
+ }
77
+ return;
78
+ }
57
79
  const parsed = parseStdout(stdout);
58
80
  if (parsed !== null) {
59
81
  Object.assign(context, parsed);
@@ -63,3 +85,61 @@ export function mergeCommandOutput(context, stdout, stderr) {
63
85
  context.output = output;
64
86
  }
65
87
  }
88
+ function accumulateOpencodeContext(existing, incoming) {
89
+ const existingTokens = (existing.tokens ?? {});
90
+ const incomingTokens = (incoming.tokens ?? {});
91
+ const existingCache = (existingTokens.cache ?? {});
92
+ const incomingCache = (incomingTokens.cache ?? {});
93
+ const existingTools = (existing.tools ?? {});
94
+ const incomingTools = (incoming.tools ?? {});
95
+ const existingToolNames = (existingTools.names ?? []);
96
+ const incomingToolNames = (incomingTools.names ?? []);
97
+ return {
98
+ // Session: keep latest (messageId may differ per task, but session.id is the same)
99
+ session: incoming.session,
100
+ // Tokens: sum across all tasks in the same session
101
+ tokens: {
102
+ input: (existingTokens.input ?? 0) + (incomingTokens.input ?? 0),
103
+ output: (existingTokens.output ?? 0) + (incomingTokens.output ?? 0),
104
+ reasoning: (existingTokens.reasoning ?? 0) + (incomingTokens.reasoning ?? 0),
105
+ cache: {
106
+ read: (existingCache.read ?? 0) + (incomingCache.read ?? 0),
107
+ write: (existingCache.write ?? 0) + (incomingCache.write ?? 0),
108
+ },
109
+ },
110
+ // Cost: sum
111
+ cost: (existing.cost ?? 0) + (incoming.cost ?? 0),
112
+ // Tools: sum counts, union names
113
+ tools: {
114
+ count: (existingTools.count ?? 0) + (incomingTools.count ?? 0),
115
+ names: [...new Set([...existingToolNames, ...incomingToolNames])],
116
+ },
117
+ gitSnapshot: incoming.gitSnapshot ?? existing.gitSnapshot,
118
+ error: incoming.error ?? existing.error,
119
+ text: incoming.text ?? existing.text,
120
+ };
121
+ }
122
+ /**
123
+ * Resolve a dotted key path (e.g., "opencode.tokens.input") from a context object.
124
+ * When the resolved value is an object (not array, not null), render as indented JSON.
125
+ * Returns the string representation for interpolation into command args.
126
+ */
127
+ export function resolveContextValue(context, key) {
128
+ const parts = key.split(".");
129
+ let current = context;
130
+ for (const part of parts) {
131
+ if (current && typeof current === "object" && !Array.isArray(current)) {
132
+ current = current[part];
133
+ }
134
+ else {
135
+ return undefined;
136
+ }
137
+ }
138
+ if (current === undefined || current === null)
139
+ return undefined;
140
+ // Objects and arrays render as indented JSON
141
+ if (typeof current === "object") {
142
+ return JSON.stringify(current, null, 2);
143
+ }
144
+ return String(current);
145
+ }
@@ -1,3 +1,38 @@
1
1
  export function formatContextLog(context) {
2
- return `${JSON.stringify(context)}\n`;
2
+ const lines = [];
3
+ for (const [key, value] of Object.entries(context)) {
4
+ if (value === undefined || value === null)
5
+ continue;
6
+ if (key === "output" && context.opencode && typeof context.opencode === "object") {
7
+ const oc = context.opencode;
8
+ if (typeof oc.text === "string" && oc.text.length > 0)
9
+ continue;
10
+ }
11
+ if (typeof value === "string") {
12
+ const truncated = value.length > 200 ? value.slice(0, 200) + "..." : value;
13
+ lines.push(` ${key}: ${truncateOneline(truncated)}`);
14
+ }
15
+ else if (typeof value === "number" || typeof value === "boolean") {
16
+ lines.push(` ${key}: ${value}`);
17
+ }
18
+ else if (typeof value === "object") {
19
+ const json = JSON.stringify(value, null, 2);
20
+ lines.push(` ${key}:`);
21
+ lines.push(indent(json, 4));
22
+ }
23
+ }
24
+ if (lines.length === 0)
25
+ return "";
26
+ return `=== CONTEXT ===\n{\n${lines.join("\n")}\n}\n=== END CONTEXT ===\n`;
27
+ }
28
+ function truncateOneline(text) {
29
+ const oneline = text.replace(/\s+/g, " ").trim();
30
+ if (oneline.length > 300) {
31
+ return oneline.slice(0, 300) + "...";
32
+ }
33
+ return oneline;
34
+ }
35
+ function indent(text, spaces) {
36
+ const pad = " ".repeat(spaces);
37
+ return text.split("\n").map((line) => pad + line).join("\n");
3
38
  }