@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 +1 -1
- package/README.md +31 -31
- package/dist/client/project-commands.js +1 -1
- package/dist/core/command/command-runner.js +69 -2
- package/dist/core/context/context-parser.js +86 -1
- package/dist/core/context/opencode-json-parser.js +129 -0
- package/dist/core/context/template.js +20 -2
- package/dist/core/context/types.js +1 -0
- package/dist/core/loop/chain-executor.js +4 -1
- package/dist/core/loop/run-executor.js +5 -1
- package/dist/daemon/http/openapi.js +2 -2
- package/dist/daemon/index.js +9 -0
- package/dist/daemon/managers/project-manager.js +1 -1
- package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +6 -0
- package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +177 -6
- package/dist/daemon/telemetry/agent-serve-manager.js +78 -0
- package/dist/daemon/telemetry/noop-telemetry-adapter.js +1 -0
- package/dist/daemon/telemetry/open-telemetry-adapter.js +101 -22
- package/dist/entry.js +0 -0
- package/dist/shared/i18n/en.json +1 -1
- package/package.json +118 -118
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
|
|
6
6
|
**Loop engineering for your terminal. Run any command on a cadence.**
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
`loop-task` is a cross-platform CLI that runs shell commands at human-readable intervals. Create loops in the background, manage them from an interactive TUI board, or run them in the foreground. It is the **heartbeat** primitive for [loop engineering](#loop-engineering): instead of running a task by hand every time, you schedule it once and let it run.
|
|
9
9
|
|
|
10
|
-
**[
|
|
10
|
+
**[plainconceptsplatform.github.io/loop-task](https://plainconceptsplatform.github.io/loop-task)**
|
|
11
11
|
|
|
12
|
-
[](https://www.npmjs.com/package/loop-task)
|
|
13
|
+
[](https://www.npmjs.com/package/loop-task)
|
|
14
|
+
[](./LICENSE)
|
|
15
|
+
[](https://nodejs.org)
|
|
16
16
|
|
|
17
17
|
</div>
|
|
18
18
|
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
`loop-task` is that heartbeat as a tiny local primitive. Some examples:
|
|
24
24
|
|
|
25
25
|
<div align="center">
|
|
26
|
-
<img src="https://raw.githubusercontent.com/
|
|
26
|
+
<img src="https://raw.githubusercontent.com/PlainConceptsPlatform/loop-task/refs/heads/main/demo.gif" alt="loop-task demo" width="700" />
|
|
27
27
|
</div>
|
|
28
28
|
|
|
29
29
|
```bash
|
|
@@ -47,7 +47,7 @@ No cron files to maintain and no daemon to babysit: loops persist across reboots
|
|
|
47
47
|
## Quick start
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
npm install -g
|
|
50
|
+
npm install -g loop-task
|
|
51
51
|
loop-task # open the board
|
|
52
52
|
loop-task start # start the daemon, restore persisted loops
|
|
53
53
|
loop-task new 30m -- npm test # create a background loop
|
|
@@ -60,28 +60,28 @@ loop-task api # show HTTP API endpoints (Swagger, OpenAPI)
|
|
|
60
60
|
Or run it directly:
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
|
-
npx
|
|
64
|
-
npx
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
## Agent skills
|
|
68
|
-
|
|
69
|
-
Install the public Loop Task skills with:
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
npx skills add
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
The repository also contains internal development skills under `.agents/skills/`.
|
|
76
|
-
They are marked internal automatically by the repository pre-commit hook and are
|
|
77
|
-
not intended for installation by Loop Task users.
|
|
78
|
-
|
|
79
|
-
## Requirements
|
|
63
|
+
npx loop-task
|
|
64
|
+
npx loop-task new 30m -- npm test
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Agent skills
|
|
68
|
+
|
|
69
|
+
Install the public Loop Task skills with:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx skills add PlainConceptsPlatform/loop-task
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The repository also contains internal development skills under `.agents/skills/`.
|
|
76
|
+
They are marked internal automatically by the repository pre-commit hook and are
|
|
77
|
+
not intended for installation by Loop Task users.
|
|
78
|
+
|
|
79
|
+
## Requirements
|
|
80
80
|
|
|
81
81
|
- **Node.js >= 20** - required for all commands including the board
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
npm install -g
|
|
84
|
+
npm install -g loop-task
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
All commands (`start`, `new`, `run`, `board`) work with Node alone.
|
|
@@ -284,15 +284,15 @@ When tasks are arranged in a chain (on-success or on-failure), context flows bet
|
|
|
284
284
|
|
|
285
285
|
### How it works
|
|
286
286
|
|
|
287
|
-
1. **Auto-capture** - stdout and stderr from every task in the chain are captured before the next task starts.
|
|
288
|
-
2. **Parse rules** - stdout is parsed by content type:
|
|
289
|
-
- **JSON object** (`{"key": "value"}`) - each key is merged into the shared context.
|
|
287
|
+
1. **Auto-capture** - stdout and stderr from every task in the chain are captured before the next task starts.
|
|
288
|
+
2. **Parse rules** - stdout is parsed by content type:
|
|
289
|
+
- **JSON object** (`{"key": "value"}`) - each key is merged into the shared context.
|
|
290
290
|
- **JSONL** (one JSON object per line) - each line's keys are merged in order.
|
|
291
|
-
- **Plain text** - stored under a single `output` key.
|
|
291
|
+
- **Plain text** - stored under a single `output` key.
|
|
292
292
|
- **Empty output** - no change to context.
|
|
293
293
|
3. **Template interpolation** - use `{{key}}` in the command or arguments of any task. Before spawning, `{{key}}` is replaced with the current value of `key` from the shared context.
|
|
294
294
|
4. **Merge semantics** - keys accumulate across the chain. Task 1 produces `{ "id": "42" }`, task 2 can use `{{id}}` and also add `{ "status": "ok" }`. Task 3 sees both.
|
|
295
|
-
5. **Latest command output** - `{{output}}` always contains stdout plus stderr from the immediately preceding task. JSON fields such as `{{number}}`, `{{title}}`, and `{{body}}` remain available until a later task explicitly emits the same key.
|
|
295
|
+
5. **Latest command output** - `{{output}}` always contains stdout plus stderr from the immediately preceding task. JSON fields such as `{{number}}`, `{{title}}`, and `{{body}}` remain available until a later task explicitly emits the same key.
|
|
296
296
|
6. **Context lifecycle** - context is built fresh each loop iteration and exists only in memory. It is never persisted to disk.
|
|
297
297
|
|
|
298
298
|
### Example: Issue Refinement Chain
|
|
@@ -74,7 +74,7 @@ export async function createProjectCli(name, colorInput, directory, githubSource
|
|
|
74
74
|
try {
|
|
75
75
|
if (githubSource !== undefined && githubSource !== "") {
|
|
76
76
|
if (!/^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/.test(githubSource)) {
|
|
77
|
-
throw new Error(`Invalid github-source format: "${githubSource}". Expected owner/repo (e.g.
|
|
77
|
+
throw new Error(`Invalid github-source format: "${githubSource}". Expected owner/repo (e.g. PlainConceptsPlatform/loop-task)`);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
const color = colorInput ? resolveColor(colorInput) : PROJECT_COLORS.cyan;
|
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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" } } } },
|
package/dist/daemon/index.js
CHANGED
|
@@ -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.
|
|
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() {
|