@plainconceptsplatform/loop-task 2.6.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Quique Fdez Guerra
3
+ Copyright (c) 2026 Plain Concepts
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -5,14 +5,14 @@
5
5
 
6
6
  **Loop engineering for your terminal. Run any command on a cadence.**
7
7
 
8
- `@plainconceptsplatform/loop-task` is a cross-platform CLI that runs shell commands at human-readable intervals. Create loops in the background, manage them from an interactive TUI board, or run them in the foreground. It is the **heartbeat** primitive for [loop engineering](#loop-engineering): instead of running a task by hand every time, you schedule it once and let it run.
8
+ `loop-task` is a cross-platform CLI that runs shell commands at human-readable intervals. Create loops in the background, manage them from an interactive TUI board, or run them in the foreground. It is the **heartbeat** primitive for [loop engineering](#loop-engineering): instead of running a task by hand every time, you schedule it once and let it run.
9
9
 
10
- **[loop.ckgrafico.com](https://loop.ckgrafico.com)**
10
+ **[plainconceptsplatform.github.io/loop-task](https://plainconceptsplatform.github.io/loop-task)**
11
11
 
12
- [![npm version](https://img.shields.io/npm/v/@plainconceptsplatform/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/@plainconceptsplatform/loop-task)
13
- [![npm downloads](https://img.shields.io/npm/dm/@plainconceptsplatform/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/@plainconceptsplatform/loop-task)
14
- [![license](https://img.shields.io/npm/l/@plainconceptsplatform/loop-task?style=flat-square&color=black)](./LICENSE)
15
- [![node](https://img.shields.io/node/v/@plainconceptsplatform/loop-task?style=flat-square&color=black)](https://nodejs.org)
12
+ [![npm version](https://img.shields.io/npm/v/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/loop-task)
13
+ [![npm downloads](https://img.shields.io/npm/dm/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/loop-task)
14
+ [![license](https://img.shields.io/npm/l/loop-task?style=flat-square&color=black)](./LICENSE)
15
+ [![node](https://img.shields.io/node/v/loop-task?style=flat-square&color=black)](https://nodejs.org)
16
16
 
17
17
  </div>
18
18
 
@@ -23,7 +23,7 @@
23
23
  `loop-task` is that heartbeat as a tiny local primitive. Some examples:
24
24
 
25
25
  <div align="center">
26
- <img src="https://raw.githubusercontent.com/plainconceptsplatform/loop-task/refs/heads/main/demo.gif" alt="loop-task demo" width="700" />
26
+ <img src="https://raw.githubusercontent.com/PlainConceptsPlatform/loop-task/refs/heads/main/demo.gif" alt="loop-task demo" width="700" />
27
27
  </div>
28
28
 
29
29
  ```bash
@@ -47,7 +47,7 @@ No cron files to maintain and no daemon to babysit: loops persist across reboots
47
47
  ## Quick start
48
48
 
49
49
  ```bash
50
- npm install -g @plainconceptsplatform/loop-task
50
+ npm install -g loop-task
51
51
  loop-task # open the board
52
52
  loop-task start # start the daemon, restore persisted loops
53
53
  loop-task new 30m -- npm test # create a background loop
@@ -60,28 +60,28 @@ loop-task api # show HTTP API endpoints (Swagger, OpenAPI)
60
60
  Or run it directly:
61
61
 
62
62
  ```bash
63
- npx @plainconceptsplatform/loop-task
64
- npx @plainconceptsplatform/loop-task new 30m -- npm test
65
- ```
66
-
67
- ## Agent skills
68
-
69
- Install the public Loop Task skills with:
70
-
71
- ```bash
72
- npx skills add plainconceptsplatform/loop-task
73
- ```
74
-
75
- The repository also contains internal development skills under `.agents/skills/`.
76
- They are marked internal automatically by the repository pre-commit hook and are
77
- not intended for installation by Loop Task users.
78
-
79
- ## Requirements
63
+ npx loop-task
64
+ npx loop-task new 30m -- npm test
65
+ ```
66
+
67
+ ## Agent skills
68
+
69
+ Install the public Loop Task skills with:
70
+
71
+ ```bash
72
+ npx skills add PlainConceptsPlatform/loop-task
73
+ ```
74
+
75
+ The repository also contains internal development skills under `.agents/skills/`.
76
+ They are marked internal automatically by the repository pre-commit hook and are
77
+ not intended for installation by Loop Task users.
78
+
79
+ ## Requirements
80
80
 
81
81
  - **Node.js >= 20** - required for all commands including the board
82
82
 
83
83
  ```bash
84
- npm install -g @plainconceptsplatform/loop-task
84
+ npm install -g loop-task
85
85
  ```
86
86
 
87
87
  All commands (`start`, `new`, `run`, `board`) work with Node alone.
@@ -284,15 +284,15 @@ When tasks are arranged in a chain (on-success or on-failure), context flows bet
284
284
 
285
285
  ### How it works
286
286
 
287
- 1. **Auto-capture** - stdout and stderr from every task in the chain are captured before the next task starts.
288
- 2. **Parse rules** - stdout is parsed by content type:
289
- - **JSON object** (`{"key": "value"}`) - each key is merged into the shared context.
287
+ 1. **Auto-capture** - stdout and stderr from every task in the chain are captured before the next task starts.
288
+ 2. **Parse rules** - stdout is parsed by content type:
289
+ - **JSON object** (`{"key": "value"}`) - each key is merged into the shared context.
290
290
  - **JSONL** (one JSON object per line) - each line's keys are merged in order.
291
- - **Plain text** - stored under a single `output` key.
291
+ - **Plain text** - stored under a single `output` key.
292
292
  - **Empty output** - no change to context.
293
293
  3. **Template interpolation** - use `{{key}}` in the command or arguments of any task. Before spawning, `{{key}}` is replaced with the current value of `key` from the shared context.
294
294
  4. **Merge semantics** - keys accumulate across the chain. Task 1 produces `{ "id": "42" }`, task 2 can use `{{id}}` and also add `{ "status": "ok" }`. Task 3 sees both.
295
- 5. **Latest command output** - `{{output}}` always contains stdout plus stderr from the immediately preceding task. JSON fields such as `{{number}}`, `{{title}}`, and `{{body}}` remain available until a later task explicitly emits the same key.
295
+ 5. **Latest command output** - `{{output}}` always contains stdout plus stderr from the immediately preceding task. JSON fields such as `{{number}}`, `{{title}}`, and `{{body}}` remain available until a later task explicitly emits the same key.
296
296
  6. **Context lifecycle** - context is built fresh each loop iteration and exists only in memory. It is never persisted to disk.
297
297
 
298
298
  ### Example: Issue Refinement Chain
@@ -74,7 +74,7 @@ export async function createProjectCli(name, colorInput, directory, githubSource
74
74
  try {
75
75
  if (githubSource !== undefined && githubSource !== "") {
76
76
  if (!/^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/.test(githubSource)) {
77
- throw new Error(`Invalid github-source format: "${githubSource}". Expected owner/repo (e.g. CKGrafico/loop-task)`);
77
+ throw new Error(`Invalid github-source format: "${githubSource}". Expected owner/repo (e.g. PlainConceptsPlatform/loop-task)`);
78
78
  }
79
79
  }
80
80
  const color = colorInput ? resolveColor(colorInput) : PROJECT_COLORS.cyan;
@@ -8,6 +8,7 @@ import { StdoutCaptureTransform } from "./stdout-capture-transform.js";
8
8
  import { killProcessTree } from "./process-tree.js";
9
9
  import { getAgentIntegrations } 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 "''";
@@ -82,8 +83,6 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
82
83
  logStream.write(t("loop.exitMarker", { code: 1, duration: formatDuration(0) }));
83
84
  return { exitCode: 1, duration: 0, startedAt, endedAt };
84
85
  }
85
- const shellCommand = formatCommandLine(command, commandArgs);
86
- const needShell = /(\$\(|`|&&|\|\||;|>|<|\|)/.test(shellCommand);
87
86
  // Per-task telemetry override: skip telemetry for this command entirely
88
87
  const taskTelemetryDisabled = telemetryCtx?.telemetryConfig?.enabled === false;
89
88
  // Telemetry: create command span and prepare child env
@@ -121,6 +120,56 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
121
120
  commandSpan.setAttribute("loop_task.agent.integration", detectedIntegrationId);
122
121
  }
123
122
  }
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;
127
+ if (detectedIntegrationId) {
128
+ const allIntegrations = getAgentIntegrations();
129
+ const integration = allIntegrations.find((i) => i.id === detectedIntegrationId);
130
+ if (integration?.ensureServe && integration?.isServeAlive && integration?.prepareRunArgs) {
131
+ try {
132
+ // Ensure serve is started (no-op if already running)
133
+ const serveEnv = {};
134
+ // If we have telemetry env with OTEL config, pass it to serve
135
+ if (telemetryEnv.OTEL_EXPORTER_OTLP_ENDPOINT) {
136
+ serveEnv.OTEL_EXPORTER_OTLP_ENDPOINT = telemetryEnv.OTEL_EXPORTER_OTLP_ENDPOINT;
137
+ serveEnv.OTEL_EXPORTER_OTLP_PROTOCOL = telemetryEnv.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
138
+ serveEnv.OTEL_TRACES_EXPORTER = "otlp";
139
+ serveEnv.OTEL_METRICS_EXPORTER = "otlp";
140
+ serveEnv.OTEL_LOGS_EXPORTER = "otlp";
141
+ if (telemetryEnv.OTEL_RESOURCE_ATTRIBUTES) {
142
+ serveEnv.OTEL_RESOURCE_ATTRIBUTES = telemetryEnv.OTEL_RESOURCE_ATTRIBUTES;
143
+ }
144
+ if (telemetryEnv.OPENCODE_EXPERIMENTAL_OPEN_TELEMETRY) {
145
+ serveEnv.OPENCODE_EXPERIMENTAL_OPEN_TELEMETRY = telemetryEnv.OPENCODE_EXPERIMENTAL_OPEN_TELEMETRY;
146
+ }
147
+ }
148
+ await integration.ensureServe(cwd || process.cwd(), serveEnv);
149
+ if (integration.isServeAlive()) {
150
+ effectiveArgs = integration.prepareRunArgs(commandArgs, cwd || undefined);
151
+ if (commandSpan) {
152
+ commandSpan.setAttribute("loop_task.agent.serve_attached", true);
153
+ }
154
+ }
155
+ }
156
+ catch (err) {
157
+ // Serve failure is non-fatal — fall back to cold start
158
+ if (commandSpan) {
159
+ commandSpan.setAttribute("loop_task.agent.serve_error", String(err));
160
+ }
161
+ }
162
+ }
163
+ }
164
+ // Auto-inject --format json for opencode run so we can parse structured output
165
+ if (detectedIntegrationId === "opencode" && effectiveArgs[0] === "run") {
166
+ if (!effectiveArgs.includes("--format") && !effectiveArgs.includes("-f")) {
167
+ effectiveArgs = [...effectiveArgs.slice(0, 1), "--format", "json", ...effectiveArgs.slice(1)];
168
+ }
169
+ }
170
+ // Compute shell command after potential --attach injection
171
+ const shellCommand = formatCommandLine(effectiveCommand, effectiveArgs);
172
+ const needShell = /(\$\(|`|&&|\|\||;|>|<|\|)/.test(shellCommand);
124
173
  const baseEnv = childEnv();
125
174
  const fileEnv = loadEnvFile();
126
175
  const mergedEnv = {
@@ -194,6 +243,24 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
194
243
  if (detectedIntegrationId && stdoutCapture) {
195
244
  tryParseAgentUsage(telemetryCtx.telemetry, detectedIntegrationId, { exitCode: result.exitCode ?? 0, stdout: stdoutCapture.getCaptured(), duration }, commandSpan);
196
245
  }
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
+ }
263
+ }
197
264
  if (commandSpan) {
198
265
  commandSpan.ok();
199
266
  }
@@ -53,13 +53,98 @@ 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
57
  const parsed = parseStdout(stdout);
58
58
  if (parsed !== null) {
59
59
  Object.assign(context, parsed);
60
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
+ if (opencode && typeof opencode === "object") {
67
+ const incoming = opencode;
68
+ 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
75
+ context.opencode = accumulateOpencodeContext(existing, incoming);
76
+ }
77
+ else {
78
+ // Different session or no existing context — overwrite
79
+ context.opencode = opencode;
80
+ }
81
+ }
61
82
  const output = [stdout, stderr].filter(Boolean).join("\n").trim();
62
83
  if (output.length > 0) {
63
84
  context.output = output;
64
85
  }
65
86
  }
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
+ function accumulateOpencodeContext(existing, incoming) {
93
+ const existingTokens = (existing.tokens ?? {});
94
+ const incomingTokens = (incoming.tokens ?? {});
95
+ const existingCache = (existingTokens.cache ?? {});
96
+ const incomingCache = (incomingTokens.cache ?? {});
97
+ const existingTools = (existing.tools ?? {});
98
+ const incomingTools = (incoming.tools ?? {});
99
+ const existingToolNames = (existingTools.names ?? []);
100
+ const incomingToolNames = (incomingTools.names ?? []);
101
+ return {
102
+ // Session: keep latest (messageId may differ per task, but session.id is the same)
103
+ session: incoming.session,
104
+ // Tokens: sum across all tasks in the same session
105
+ tokens: {
106
+ input: (existingTokens.input ?? 0) + (incomingTokens.input ?? 0),
107
+ output: (existingTokens.output ?? 0) + (incomingTokens.output ?? 0),
108
+ reasoning: (existingTokens.reasoning ?? 0) + (incomingTokens.reasoning ?? 0),
109
+ cache: {
110
+ read: (existingCache.read ?? 0) + (incomingCache.read ?? 0),
111
+ write: (existingCache.write ?? 0) + (incomingCache.write ?? 0),
112
+ },
113
+ },
114
+ // Cost: sum
115
+ cost: (existing.cost ?? 0) + (incoming.cost ?? 0),
116
+ // Tools: sum counts, union names
117
+ tools: {
118
+ count: (existingTools.count ?? 0) + (incomingTools.count ?? 0),
119
+ names: [...new Set([...existingToolNames, ...incomingToolNames])],
120
+ },
121
+ // Latest values (overwrite, not accumulate)
122
+ gitSnapshot: incoming.gitSnapshot ?? existing.gitSnapshot,
123
+ error: incoming.error ?? existing.error,
124
+ text: incoming.text ?? existing.text,
125
+ };
126
+ }
127
+ /**
128
+ * Resolve a dotted key path (e.g., "opencode.tokens.input") from a context object.
129
+ * When the resolved value is an object (not array, not null), render as indented JSON.
130
+ * Returns the string representation for interpolation into command args.
131
+ */
132
+ export function resolveContextValue(context, key) {
133
+ const parts = key.split(".");
134
+ let current = context;
135
+ for (const part of parts) {
136
+ if (current && typeof current === "object" && !Array.isArray(current)) {
137
+ current = current[part];
138
+ }
139
+ else {
140
+ return undefined;
141
+ }
142
+ }
143
+ if (current === undefined || current === null)
144
+ return undefined;
145
+ // Objects and arrays render as indented JSON
146
+ if (typeof current === "object") {
147
+ return JSON.stringify(current, null, 2);
148
+ }
149
+ return String(current);
150
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Parse the JSONL stdout stream from `opencode run --format json`.
3
+ *
4
+ * Each line is a JSON object with a `type` field. The parser extracts:
5
+ * - step_start: session ID, message ID, git snapshot
6
+ * - tool_use: tool name, accumulated count
7
+ * - text: last text before step_finish with reason=stop (Option B)
8
+ * - step_finish: tokens, cost (accumulated across all steps), final git snapshot
9
+ * - error: error name and message
10
+ *
11
+ * Returns null if the input is not valid JSONL with a `type` field.
12
+ */
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 {
23
+ return null;
24
+ }
25
+ if (typeof firstParsed !== "object" || firstParsed === null || !("type" in firstParsed)) {
26
+ return null;
27
+ }
28
+ const result = {
29
+ session: { id: "", messageId: "" },
30
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
31
+ cost: 0,
32
+ tools: { count: 0, names: [] },
33
+ gitSnapshot: "",
34
+ error: null,
35
+ text: null,
36
+ };
37
+ let lastTextBeforeStop = null;
38
+ 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
+ }
47
+ const type = event.type;
48
+ const part = event.part;
49
+ switch (type) {
50
+ case "step_start": {
51
+ if (event.sessionID) {
52
+ result.session.id = event.sessionID;
53
+ }
54
+ if (part?.messageID) {
55
+ result.session.messageId = part.messageID;
56
+ }
57
+ if (part?.snapshot) {
58
+ result.gitSnapshot = part.snapshot;
59
+ }
60
+ break;
61
+ }
62
+ case "tool_use": {
63
+ result.tools.count++;
64
+ const toolName = part?.tool;
65
+ if (toolName && !result.tools.names.includes(toolName)) {
66
+ result.tools.names.push(toolName);
67
+ }
68
+ break;
69
+ }
70
+ case "text": {
71
+ // Track text events; we'll capture the last one before step_finish(reason=stop)
72
+ const textContent = part?.text;
73
+ if (textContent) {
74
+ lastTextBeforeStop = textContent;
75
+ }
76
+ break;
77
+ }
78
+ case "step_finish": {
79
+ if (part?.reason === "stop") {
80
+ sawStepFinishStop = true;
81
+ // Capture the text accumulated so far
82
+ result.text = lastTextBeforeStop;
83
+ lastTextBeforeStop = null;
84
+ }
85
+ // Accumulate tokens and cost
86
+ const tokens = part?.tokens;
87
+ if (tokens) {
88
+ if (typeof tokens.input === "number")
89
+ result.tokens.input += tokens.input;
90
+ if (typeof tokens.output === "number")
91
+ result.tokens.output += tokens.output;
92
+ if (typeof tokens.reasoning === "number")
93
+ result.tokens.reasoning += tokens.reasoning;
94
+ const cache = tokens.cache;
95
+ if (cache) {
96
+ if (typeof cache.read === "number")
97
+ result.tokens.cache.read += cache.read;
98
+ if (typeof cache.write === "number")
99
+ result.tokens.cache.write += cache.write;
100
+ }
101
+ }
102
+ if (typeof part?.cost === "number") {
103
+ result.cost += part.cost;
104
+ }
105
+ if (part?.snapshot) {
106
+ result.gitSnapshot = part.snapshot;
107
+ }
108
+ break;
109
+ }
110
+ case "error": {
111
+ const error = event.error;
112
+ if (error) {
113
+ const name = error.name;
114
+ const data = error.data;
115
+ result.error = {
116
+ name: name ?? "UnknownError",
117
+ message: data?.message ?? "Unknown error",
118
+ };
119
+ }
120
+ break;
121
+ }
122
+ }
123
+ }
124
+ // If we never saw a step_finish with reason=stop, don't set text
125
+ if (!sawStepFinishStop) {
126
+ result.text = null;
127
+ }
128
+ return result;
129
+ }
@@ -10,11 +10,29 @@ function shellEscape(value) {
10
10
  // $, ", \, parens) except single quotes themselves.
11
11
  return "'" + value.replace(/'/g, "'\\''") + "'";
12
12
  }
13
+ function resolveNestedValue(context, key) {
14
+ const parts = key.split(".");
15
+ let current = context;
16
+ for (const part of parts) {
17
+ if (current && typeof current === "object" && !Array.isArray(current)) {
18
+ current = current[part];
19
+ }
20
+ else {
21
+ return undefined;
22
+ }
23
+ }
24
+ return current;
25
+ }
13
26
  export function interpolate(input, context) {
14
- return input.replace(/{{(\w+)}}/g, (_, key) => {
15
- const raw = context[key];
27
+ // Support nested keys like {{opencode.tokens.input}} and {{opencode.tokens}}
28
+ return input.replace(/{{([\w.]+)}}/g, (_, key) => {
29
+ const raw = resolveNestedValue(context, key);
16
30
  if (raw === undefined || raw === null)
17
31
  return "";
32
+ // Objects and arrays render as indented JSON
33
+ if (typeof raw === "object") {
34
+ return shellEscape(JSON.stringify(raw, null, 2));
35
+ }
18
36
  return shellEscape(String(raw));
19
37
  });
20
38
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -130,7 +130,10 @@ export function executeChain(options) {
130
130
  }
131
131
  }
132
132
  if (shouldCaptureStdout && (stepStdout || stepStderr)) {
133
- mergeCommandOutput(chainContext, stepStdout, stepStderr);
133
+ const opencodeCtx = stepResults
134
+ .filter((r) => r.status === "fulfilled")
135
+ .find((r) => r.value.opencode)?.value?.opencode;
136
+ mergeCommandOutput(chainContext, stepStdout, stepStderr, opencodeCtx);
134
137
  }
135
138
  const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
136
139
  if (stepFailure) {
@@ -98,7 +98,11 @@ export async function executeRunImpl(ctrl, signal) {
98
98
  }
99
99
  }
100
100
  if (shouldCaptureStdout && (stepStdout || stepStderr)) {
101
- mergeCommandOutput(chainContext, stepStdout, stepStderr);
101
+ // Extract opencode context from the first fulfilled step result
102
+ const opencodeCtx = stepResults
103
+ .filter((r) => r.status === "fulfilled")
104
+ .find((r) => r.value.opencode)?.value?.opencode;
105
+ mergeCommandOutput(chainContext, stepStdout, stepStderr, opencodeCtx);
102
106
  }
103
107
  const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
104
108
  if (stepFailure) {
@@ -54,10 +54,10 @@ export function buildOpenApiSpec() {
54
54
  "/api/task-chains": { post: { summary: "Create a chain of tasks", tags: ["Task Chains"], requestBody: { content: { "application/json": { schema: { type: "object", required: ["tasks"], properties: { tasks: { type: "array", items: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" }, maxRuns: { type: "integer" } } } }, chain: { type: "string", enum: ["sequential-success", "sequential-failure", "none"], description: "How to wire tasks together" } } } } } }, responses: { "201": { description: "Chain created", content: { "application/json": { schema: { type: "object", properties: { taskIds: { type: "array", items: { type: "string" } } } } } } }, "400": { description: "Validation error or rollback" } } } },
55
55
  "/api/projects": {
56
56
  get: { summary: "List all projects", tags: ["Projects"], responses: { "200": { description: "Array of projects" } } },
57
- post: { summary: "Create a project", tags: ["Projects"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. CKGrafico/loop-task)" } } } } } }, responses: { "201": { description: "Project created" }, "400": { description: "Validation error" } } },
57
+ post: { summary: "Create a project", tags: ["Projects"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. PlainConceptsPlatform/loop-task)" } } } } } }, responses: { "201": { description: "Project created" }, "400": { description: "Validation error" } } },
58
58
  },
59
59
  "/api/projects/{id}": {
60
- patch: { summary: "Update a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. CKGrafico/loop-task)" } } } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" }, "400": { description: "Validation error" } } },
60
+ patch: { summary: "Update a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. PlainConceptsPlatform/loop-task)" } } } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" }, "400": { description: "Validation error" } } },
61
61
  delete: { summary: "Delete a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Deleted" }, "400": { description: "Cannot delete system project" } } },
62
62
  },
63
63
  "/api/settings": { get: { summary: "Get daemon settings", tags: ["Settings"], responses: { "200": { description: "Current settings", content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } } } }, patch: { summary: "Update daemon settings", tags: ["Settings"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } }, responses: { "200": { description: "Updated settings", content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } }, "400": { description: "Validation error" } } } },
@@ -16,6 +16,7 @@ import { RecipeTaskStore } from "./recipe/task-store.js";
16
16
  import { DeferredReloadManager } from "./recipe/deferred-reload.js";
17
17
  import { setRecipeSelfWriteNotifier } from "./recipe/file-writer.js";
18
18
  import { TelemetryManager } from "./telemetry/index.js";
19
+ import { AgentServeManager } from "./telemetry/agent-serve-manager.js";
19
20
  import path from "node:path";
20
21
  function cleanupStaleProcesses() {
21
22
  const oldPid = readDaemonPid();
@@ -36,6 +37,7 @@ async function main() {
36
37
  settingsManager.load();
37
38
  const telemetryManager = new TelemetryManager(settingsManager.get());
38
39
  manager.setTelemetryManager(telemetryManager);
40
+ const agentServeManager = new AgentServeManager(settingsManager.get(), telemetryManager);
39
41
  const server = new IpcServer(manager, taskManager, settingsManager, telemetryManager);
40
42
  const projectManager = manager.projectManager;
41
43
  const httpServer = new HttpApiServer(manager, taskManager, projectManager, settingsManager);
@@ -150,6 +152,12 @@ async function main() {
150
152
  // Reconcile after watchers are attached so files created during startup cannot be missed.
151
153
  recipeScanner.scanAllProjects();
152
154
  daemonLog(`recipe scanner initialized`);
155
+ // Start agent serve sidecars (e.g., opencode serve) after core services are up
156
+ if (settingsManager.get().telemetryEnabled) {
157
+ agentServeManager.ensureAllServe(process.cwd()).catch((err) => {
158
+ daemonLog(`agent serve startup failed: ${String(err)}`);
159
+ });
160
+ }
153
161
  let shuttingDown = false;
154
162
  const cleanup = async () => {
155
163
  if (shuttingDown)
@@ -158,6 +166,7 @@ async function main() {
158
166
  daemonLog(`shutting down pid=${process.pid}`);
159
167
  try {
160
168
  killAllActiveProcesses();
169
+ await agentServeManager.shutdownAll();
161
170
  await telemetryManager.shutdown();
162
171
  fileWatcher.stop();
163
172
  removeDaemonPid();
@@ -11,7 +11,7 @@ export function setProjectSelfWriteNotifier(notifier) {
11
11
  const GITHUB_SOURCE_REGEX = /^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/;
12
12
  function validateGithubSource(githubSource) {
13
13
  if (!GITHUB_SOURCE_REGEX.test(githubSource)) {
14
- throw new Error(`Invalid githubSource format: "${githubSource}". Expected owner/repo (e.g. CKGrafico/loop-task)`);
14
+ throw new Error(`Invalid githubSource format: "${githubSource}". Expected owner/repo (e.g. PlainConceptsPlatform/loop-task)`);
15
15
  }
16
16
  }
17
17
  export class ProjectManager {
@@ -2,6 +2,12 @@ const CLAUDE_BINARIES = ["claude"];
2
2
  /**
3
3
  * Detects `claude -p ...` or `claude --print ...` invocations and
4
4
  * enables Claude Code's native telemetry, routing to loop-task's endpoint.
5
+ *
6
+ * Claude Code does not support a persistent serve model with `--attach`
7
+ * equivalent. All telemetry env is injected per-task as fallback.
8
+ * When Claude Code adds serve-attach support, implement the serve
9
+ * lifecycle methods (ensureServe, isServeAlive, prepareRunArgs,
10
+ * shutdownServe) here.
5
11
  */
6
12
  export class ClaudeCodeTelemetryIntegration {
7
13
  constructor() {