@plainconceptsplatform/loop-task 2.6.1 → 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/dist/app/App.js +5 -1
- package/dist/app/Splash.js +19 -0
- package/dist/client/ipc.js +8 -1
- package/dist/core/command/command-runner.js +60 -30
- package/dist/core/command/stdout-capture-transform.js +57 -7
- package/dist/core/context/context-parser.js +17 -22
- package/dist/core/context/log-context.js +36 -1
- package/dist/core/context/opencode-json-parser.js +16 -19
- package/dist/core/context/template.js +0 -2
- package/dist/core/loop/chain-executor.js +1 -0
- package/dist/core/loop/run-executor.js +1 -0
- package/dist/daemon/index.js +6 -10
- package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +5 -6
- package/package.json +1 -1
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;
|
|
@@ -6,7 +6,7 @@ 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
11
|
import { parseOpencodeJsonOutput } from "../context/opencode-json-parser.js";
|
|
12
12
|
function quoteArg(arg) {
|
|
@@ -68,12 +68,13 @@ export function killAllActiveProcesses() {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
export async function executeCommand(command, commandArgs, cwd, logStream, signal, runNumber, captureStdout = false, isChain = false, telemetryCtx) {
|
|
71
|
+
let effectiveCommand = command;
|
|
72
|
+
let effectiveArgs = commandArgs;
|
|
71
73
|
const startedAt = new Date();
|
|
72
74
|
if (!isChain) {
|
|
73
75
|
const header = t("loop.runHeader", { timestamp: startedAt.toLocaleString(), runNumber: runNumber ?? 0 });
|
|
74
76
|
logStream.write(header);
|
|
75
77
|
}
|
|
76
|
-
logStream.write(t("loop.commandLine", { command: formatCommandLine(command, commandArgs) }));
|
|
77
78
|
if (cwd) {
|
|
78
79
|
logStream.write(t("loop.cwdLine", { cwd }));
|
|
79
80
|
}
|
|
@@ -120,18 +121,24 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
|
|
|
120
121
|
commandSpan.setAttribute("loop_task.agent.integration", detectedIntegrationId);
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|
|
127
136
|
if (detectedIntegrationId) {
|
|
128
137
|
const allIntegrations = getAgentIntegrations();
|
|
129
138
|
const integration = allIntegrations.find((i) => i.id === detectedIntegrationId);
|
|
130
139
|
if (integration?.ensureServe && integration?.isServeAlive && integration?.prepareRunArgs) {
|
|
131
140
|
try {
|
|
132
|
-
// Ensure serve is started (no-op if already running)
|
|
133
141
|
const serveEnv = {};
|
|
134
|
-
// If we have telemetry env with OTEL config, pass it to serve
|
|
135
142
|
if (telemetryEnv.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
|
136
143
|
serveEnv.OTEL_EXPORTER_OTLP_ENDPOINT = telemetryEnv.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
137
144
|
serveEnv.OTEL_EXPORTER_OTLP_PROTOCOL = telemetryEnv.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
|
|
@@ -161,13 +168,25 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
|
|
|
161
168
|
}
|
|
162
169
|
}
|
|
163
170
|
}
|
|
164
|
-
// Auto-inject --format json for opencode run so we can parse structured output
|
|
165
171
|
if (detectedIntegrationId === "opencode" && effectiveArgs[0] === "run") {
|
|
166
172
|
if (!effectiveArgs.includes("--format") && !effectiveArgs.includes("-f")) {
|
|
167
173
|
effectiveArgs = [...effectiveArgs.slice(0, 1), "--format", "json", ...effectiveArgs.slice(1)];
|
|
168
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
|
+
}
|
|
169
188
|
}
|
|
170
|
-
|
|
189
|
+
logStream.write(t("loop.commandLine", { command: formatCommandLine(effectiveCommand, effectiveArgs) }));
|
|
171
190
|
const shellCommand = formatCommandLine(effectiveCommand, effectiveArgs);
|
|
172
191
|
const needShell = /(\$\(|`|&&|\|\||;|>|<|\|)/.test(shellCommand);
|
|
173
192
|
const baseEnv = childEnv();
|
|
@@ -191,7 +210,7 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
|
|
|
191
210
|
killSignal: "SIGTERM",
|
|
192
211
|
...detachedOpt,
|
|
193
212
|
})
|
|
194
|
-
: execa(
|
|
213
|
+
: execa(effectiveCommand, effectiveArgs, {
|
|
195
214
|
stdout: "pipe",
|
|
196
215
|
stderr: "pipe",
|
|
197
216
|
stdin: "ignore",
|
|
@@ -205,8 +224,9 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
|
|
|
205
224
|
if (child.pid) {
|
|
206
225
|
activePids.add(child.pid);
|
|
207
226
|
}
|
|
227
|
+
const isOpencodeJson = detectedIntegrationId === "opencode" && effectiveArgs.includes("--format") && effectiveArgs.includes("json");
|
|
208
228
|
const stdoutCapture = (captureStdout || !!detectedIntegrationId)
|
|
209
|
-
? new StdoutCaptureTransform(MAX_CONTEXT_STDOUT_BYTES)
|
|
229
|
+
? new StdoutCaptureTransform(MAX_CONTEXT_STDOUT_BYTES, isOpencodeJson)
|
|
210
230
|
: null;
|
|
211
231
|
const stderrCapture = (captureStdout || !!detectedIntegrationId)
|
|
212
232
|
? new StdoutCaptureTransform(MAX_CONTEXT_STDOUT_BYTES)
|
|
@@ -239,27 +259,17 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
|
|
|
239
259
|
commandSpan.setAttribute("loop_task.command.stdout", truncateForSpan(stdoutCapture.getCaptured(), MAX_SPAN_OUTPUT_BYTES));
|
|
240
260
|
}
|
|
241
261
|
}
|
|
242
|
-
// Attempt to parse agent usage from output
|
|
243
262
|
if (detectedIntegrationId && stdoutCapture) {
|
|
244
263
|
tryParseAgentUsage(telemetryCtx.telemetry, detectedIntegrationId, { exitCode: result.exitCode ?? 0, stdout: stdoutCapture.getCaptured(), duration }, commandSpan);
|
|
245
264
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
commandSpan.setAttribute("loop_task.opencode.tools_count", opencodeCtx.tools.count);
|
|
255
|
-
if (opencodeCtx.error) {
|
|
256
|
-
commandSpan.setAttribute("loop_task.opencode.error", opencodeCtx.error.name);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
// The parsed context is returned as part of the result so context-parser
|
|
260
|
-
// can merge it into the loop chain context
|
|
261
|
-
result.opencode = opencodeCtx;
|
|
262
|
-
}
|
|
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);
|
|
263
273
|
}
|
|
264
274
|
if (commandSpan) {
|
|
265
275
|
commandSpan.ok();
|
|
@@ -271,6 +281,7 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
|
|
|
271
281
|
endedAt,
|
|
272
282
|
...(captureStdout && stdoutCapture ? { stdout: stdoutCapture.getCaptured() } : {}),
|
|
273
283
|
...(captureStdout && stderrCapture ? { stderr: stderrCapture.getCaptured() } : {}),
|
|
284
|
+
...(opencodeResult ? { opencode: opencodeResult } : {}),
|
|
274
285
|
};
|
|
275
286
|
}
|
|
276
287
|
catch (error) {
|
|
@@ -365,3 +376,22 @@ function truncateForSpan(text, maxBytes) {
|
|
|
365
376
|
return text;
|
|
366
377
|
return buf.subarray(0, maxBytes).toString("utf-8") + `\n... [truncated, ${buf.length} bytes total]`;
|
|
367
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() {
|
|
@@ -54,41 +54,37 @@ export function parseStdout(raw) {
|
|
|
54
54
|
return { output: trimmed };
|
|
55
55
|
}
|
|
56
56
|
export function mergeCommandOutput(context, stdout, stderr, opencode) {
|
|
57
|
-
const parsed = parseStdout(stdout);
|
|
58
|
-
if (parsed !== null) {
|
|
59
|
-
Object.assign(context, parsed);
|
|
60
|
-
}
|
|
61
|
-
// Merge structured opencode context under the "opencode" namespace.
|
|
62
|
-
// When the new opencode context references the same session as the existing
|
|
63
|
-
// one, accumulate tokens/cost/tools instead of overwriting. This gives the
|
|
64
|
-
// last task in a chain visibility into total session usage, not just the
|
|
65
|
-
// last task's usage.
|
|
66
57
|
if (opencode && typeof opencode === "object") {
|
|
67
58
|
const incoming = opencode;
|
|
68
59
|
const existing = context.opencode;
|
|
69
|
-
if (existing &&
|
|
70
|
-
typeof existing === "object" &&
|
|
71
|
-
existing.session &&
|
|
72
|
-
incoming.session &&
|
|
73
|
-
existing.session.id === incoming.session.id) {
|
|
74
|
-
// Same session — accumulate
|
|
60
|
+
if (existing && typeof existing === "object" && existing.session && incoming.session && existing.session.id === incoming.session.id) {
|
|
75
61
|
context.opencode = accumulateOpencodeContext(existing, incoming);
|
|
76
62
|
}
|
|
77
63
|
else {
|
|
78
|
-
// Different session or no existing context — overwrite
|
|
79
64
|
context.opencode = opencode;
|
|
80
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
|
+
}
|
|
79
|
+
const parsed = parseStdout(stdout);
|
|
80
|
+
if (parsed !== null) {
|
|
81
|
+
Object.assign(context, parsed);
|
|
81
82
|
}
|
|
82
83
|
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
83
84
|
if (output.length > 0) {
|
|
84
85
|
context.output = output;
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
|
-
/**
|
|
88
|
-
* Accumulate tokens, cost, and tool counts across two opencode context objects
|
|
89
|
-
* that share the same session ID. Non-accumulatable fields (session, gitSnapshot,
|
|
90
|
-
* error, text) are overwritten with the latest values.
|
|
91
|
-
*/
|
|
92
88
|
function accumulateOpencodeContext(existing, incoming) {
|
|
93
89
|
const existingTokens = (existing.tokens ?? {});
|
|
94
90
|
const incomingTokens = (incoming.tokens ?? {});
|
|
@@ -118,7 +114,6 @@ function accumulateOpencodeContext(existing, incoming) {
|
|
|
118
114
|
count: (existingTools.count ?? 0) + (incomingTools.count ?? 0),
|
|
119
115
|
names: [...new Set([...existingToolNames, ...incomingToolNames])],
|
|
120
116
|
},
|
|
121
|
-
// Latest values (overwrite, not accumulate)
|
|
122
117
|
gitSnapshot: incoming.gitSnapshot ?? existing.gitSnapshot,
|
|
123
118
|
error: incoming.error ?? existing.error,
|
|
124
119
|
text: incoming.text ?? existing.text,
|
|
@@ -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
|
}
|
|
@@ -11,20 +11,23 @@
|
|
|
11
11
|
* Returns null if the input is not valid JSONL with a `type` field.
|
|
12
12
|
*/
|
|
13
13
|
export function parseOpencodeJsonOutput(stdout) {
|
|
14
|
-
const
|
|
15
|
-
if (
|
|
16
|
-
return null;
|
|
17
|
-
// Verify first line is valid JSON with a `type` field
|
|
18
|
-
let firstParsed;
|
|
19
|
-
try {
|
|
20
|
-
firstParsed = JSON.parse(lines[0]);
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
14
|
+
const allLines = stdout.trim().split("\n").filter((l) => l.trim());
|
|
15
|
+
if (allLines.length === 0)
|
|
23
16
|
return null;
|
|
17
|
+
const jsonLines = [];
|
|
18
|
+
for (const line of allLines) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(line);
|
|
21
|
+
if (typeof parsed === "object" && parsed !== null && "type" in parsed) {
|
|
22
|
+
jsonLines.push(parsed);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// skip non-JSON lines (opencode prints status lines)
|
|
27
|
+
}
|
|
24
28
|
}
|
|
25
|
-
if (
|
|
29
|
+
if (jsonLines.length === 0)
|
|
26
30
|
return null;
|
|
27
|
-
}
|
|
28
31
|
const result = {
|
|
29
32
|
session: { id: "", messageId: "" },
|
|
30
33
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
@@ -33,17 +36,11 @@ export function parseOpencodeJsonOutput(stdout) {
|
|
|
33
36
|
gitSnapshot: "",
|
|
34
37
|
error: null,
|
|
35
38
|
text: null,
|
|
39
|
+
model: null,
|
|
36
40
|
};
|
|
37
41
|
let lastTextBeforeStop = null;
|
|
38
42
|
let sawStepFinishStop = false;
|
|
39
|
-
for (const
|
|
40
|
-
let event;
|
|
41
|
-
try {
|
|
42
|
-
event = JSON.parse(line);
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
43
|
+
for (const event of jsonLines) {
|
|
47
44
|
const type = event.type;
|
|
48
45
|
const part = event.part;
|
|
49
46
|
switch (type) {
|
|
@@ -24,12 +24,10 @@ function resolveNestedValue(context, key) {
|
|
|
24
24
|
return current;
|
|
25
25
|
}
|
|
26
26
|
export function interpolate(input, context) {
|
|
27
|
-
// Support nested keys like {{opencode.tokens.input}} and {{opencode.tokens}}
|
|
28
27
|
return input.replace(/{{([\w.]+)}}/g, (_, key) => {
|
|
29
28
|
const raw = resolveNestedValue(context, key);
|
|
30
29
|
if (raw === undefined || raw === null)
|
|
31
30
|
return "";
|
|
32
|
-
// Objects and arrays render as indented JSON
|
|
33
31
|
if (typeof raw === "object") {
|
|
34
32
|
return shellEscape(JSON.stringify(raw, null, 2));
|
|
35
33
|
}
|
package/dist/daemon/index.js
CHANGED
|
@@ -52,23 +52,19 @@ async function main() {
|
|
|
52
52
|
const httpPort = parseInt(process.env.LOOP_CLI_HTTP_PORT ?? "", 10);
|
|
53
53
|
const resolvedHttpPort = Number.isNaN(httpPort) ? undefined : httpPort;
|
|
54
54
|
let currentHttpHost = settingsManager.get().httpApiHost;
|
|
55
|
+
const startupTasks = [];
|
|
55
56
|
if (settingsManager.get().httpApiEnabled) {
|
|
56
|
-
|
|
57
|
-
await httpServer.listen(resolvedHttpPort, currentHttpHost);
|
|
58
|
-
}
|
|
59
|
-
catch (err) {
|
|
57
|
+
startupTasks.push(httpServer.listen(resolvedHttpPort, currentHttpHost).catch((err) => {
|
|
60
58
|
daemonLog(`HTTP API server failed to start: ${String(err)}`);
|
|
61
|
-
}
|
|
59
|
+
}));
|
|
62
60
|
}
|
|
63
61
|
mcpServer.setHost(currentHttpHost);
|
|
64
62
|
if (mcpEnabled) {
|
|
65
|
-
|
|
66
|
-
await mcpServer.start();
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
63
|
+
startupTasks.push(mcpServer.start().catch((err) => {
|
|
69
64
|
daemonLog(`MCP server failed to start: ${String(err)}`);
|
|
70
|
-
}
|
|
65
|
+
}));
|
|
71
66
|
}
|
|
67
|
+
await Promise.all(startupTasks);
|
|
72
68
|
settingsManager.onChange((settings) => {
|
|
73
69
|
const newHost = settings.httpApiHost;
|
|
74
70
|
const hostChanged = newHost !== currentHttpHost;
|
|
@@ -138,13 +138,13 @@ export class OpenCodeTelemetryIntegration {
|
|
|
138
138
|
});
|
|
139
139
|
this.serveProcess.catch((err) => {
|
|
140
140
|
daemonLog(`opencode-serve: process error: ${String(err)}`);
|
|
141
|
-
this.serveProcess = null;
|
|
142
|
-
this.serveInfo = null;
|
|
143
141
|
});
|
|
144
142
|
this.serveProcess.on("exit", (code) => {
|
|
145
143
|
daemonLog(`opencode-serve: process exited with code ${code}`);
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
if (code !== 0 && code !== null) {
|
|
145
|
+
this.serveProcess = null;
|
|
146
|
+
this.serveInfo = null;
|
|
147
|
+
}
|
|
148
148
|
});
|
|
149
149
|
// Wait for health check
|
|
150
150
|
const healthy = await this.waitForHealth();
|
|
@@ -167,8 +167,7 @@ export class OpenCodeTelemetryIntegration {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
isServeAlive() {
|
|
170
|
-
|
|
171
|
-
if (!this.serveProcess || !this.serveInfo)
|
|
170
|
+
if (!this.serveInfo)
|
|
172
171
|
return false;
|
|
173
172
|
return true;
|
|
174
173
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainconceptsplatform/loop-task",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.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": {
|