@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 +1 -1
- package/README.md +31 -31
- package/dist/app/App.js +5 -1
- package/dist/app/Splash.js +19 -0
- package/dist/client/ipc.js +8 -1
- package/dist/client/project-commands.js +1 -1
- package/dist/core/command/command-runner.js +104 -7
- package/dist/core/command/stdout-capture-transform.js +57 -7
- package/dist/core/context/context-parser.js +81 -1
- package/dist/core/context/log-context.js +36 -1
- package/dist/core/context/opencode-json-parser.js +126 -0
- package/dist/core/context/template.js +18 -2
- package/dist/core/context/types.js +1 -0
- package/dist/core/loop/chain-executor.js +5 -1
- package/dist/core/loop/run-executor.js +6 -1
- package/dist/daemon/http/openapi.js +2 -2
- package/dist/daemon/index.js +15 -10
- package/dist/daemon/managers/project-manager.js +1 -1
- package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +6 -0
- package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +176 -6
- package/dist/daemon/telemetry/agent-serve-manager.js +78 -0
- package/dist/daemon/telemetry/noop-telemetry-adapter.js +1 -0
- package/dist/daemon/telemetry/open-telemetry-adapter.js +101 -22
- package/dist/entry.js +0 -0
- package/dist/shared/i18n/en.json +1 -1
- package/package.json +118 -118
package/LICENSE
CHANGED
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
|
-
|
|
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
|
-
**[
|
|
10
|
+
**[plainconceptsplatform.github.io/loop-task](https://plainconceptsplatform.github.io/loop-task)**
|
|
11
11
|
|
|
12
|
-
[](https://www.npmjs.com/package/loop-task)
|
|
13
|
+
[](https://www.npmjs.com/package/loop-task)
|
|
14
|
+
[](./LICENSE)
|
|
15
|
+
[](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/
|
|
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
|
|
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
|
|
64
|
-
npx
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
## Agent skills
|
|
68
|
-
|
|
69
|
-
Install the public Loop Task skills with:
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
npx skills add
|
|
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
|
|
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
|
+
}
|
package/dist/client/ipc.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|