@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 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;
@@ -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
- // Serve lifecycle: if an agent integration with serve support is detected,
124
- // ensure the serve sidecar is running and inject --attach + --dir into args.
125
- let effectiveCommand = command;
126
- let effectiveArgs = commandArgs;
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
- // Compute shell command after potential --attach injection
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(command, commandArgs, {
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
- // Parse opencode JSONL output into context.opencode.*
247
- if (detectedIntegrationId === "opencode" && stdoutCapture) {
248
- const opencodeCtx = parseOpencodeJsonOutput(stdoutCapture.getCaptured());
249
- if (opencodeCtx) {
250
- // Emit structured log for observability
251
- if (commandSpan) {
252
- commandSpan.setAttribute("loop_task.opencode.session_id", opencodeCtx.session.id);
253
- commandSpan.setAttribute("loop_task.opencode.cost", opencodeCtx.cost);
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
- 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() {
@@ -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
- 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
  }
@@ -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 lines = stdout.trim().split("\n").filter((l) => l.trim());
15
- if (lines.length === 0)
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 (typeof firstParsed !== "object" || firstParsed === null || !("type" in firstParsed)) {
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 line of lines) {
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
  }
@@ -110,6 +110,7 @@ export function executeChain(options) {
110
110
  taskId: chainTask.id,
111
111
  taskName: chainTask.name,
112
112
  telemetryConfig: chainTask.telemetry,
113
+ chainContext,
113
114
  }
114
115
  : undefined;
115
116
  for (const step of taskSteps) {
@@ -73,6 +73,7 @@ export async function executeRunImpl(ctrl, signal) {
73
73
  taskName: task?.name,
74
74
  projectId: ctrl.options.projectId,
75
75
  telemetryConfig: task?.telemetry,
76
+ chainContext,
76
77
  }
77
78
  : undefined;
78
79
  let exitCode = 0;
@@ -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
- try {
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
- try {
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
- this.serveProcess = null;
147
- this.serveInfo = null;
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
- // Fast check: if we don't have a process reference, serve is not alive
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.6.1",
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": {