@plainconceptsplatform/loop-task 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +31 -31
- package/dist/app/App.js +5 -1
- package/dist/app/Splash.js +19 -0
- package/dist/client/ipc.js +8 -1
- package/dist/client/project-commands.js +1 -1
- package/dist/core/command/command-runner.js +104 -7
- package/dist/core/command/stdout-capture-transform.js +57 -7
- package/dist/core/context/context-parser.js +81 -1
- package/dist/core/context/log-context.js +36 -1
- package/dist/core/context/opencode-json-parser.js +126 -0
- package/dist/core/context/template.js +18 -2
- package/dist/core/context/types.js +1 -0
- package/dist/core/loop/chain-executor.js +5 -1
- package/dist/core/loop/run-executor.js +6 -1
- package/dist/daemon/http/openapi.js +2 -2
- package/dist/daemon/index.js +15 -10
- package/dist/daemon/managers/project-manager.js +1 -1
- package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +6 -0
- package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +176 -6
- package/dist/daemon/telemetry/agent-serve-manager.js +78 -0
- package/dist/daemon/telemetry/noop-telemetry-adapter.js +1 -0
- package/dist/daemon/telemetry/open-telemetry-adapter.js +101 -22
- package/dist/entry.js +0 -0
- package/dist/shared/i18n/en.json +1 -1
- package/package.json +118 -118
|
@@ -0,0 +1,126 @@
|
|
|
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 allLines = stdout.trim().split("\n").filter((l) => l.trim());
|
|
15
|
+
if (allLines.length === 0)
|
|
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
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (jsonLines.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
const result = {
|
|
32
|
+
session: { id: "", messageId: "" },
|
|
33
|
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
34
|
+
cost: 0,
|
|
35
|
+
tools: { count: 0, names: [] },
|
|
36
|
+
gitSnapshot: "",
|
|
37
|
+
error: null,
|
|
38
|
+
text: null,
|
|
39
|
+
model: null,
|
|
40
|
+
};
|
|
41
|
+
let lastTextBeforeStop = null;
|
|
42
|
+
let sawStepFinishStop = false;
|
|
43
|
+
for (const event of jsonLines) {
|
|
44
|
+
const type = event.type;
|
|
45
|
+
const part = event.part;
|
|
46
|
+
switch (type) {
|
|
47
|
+
case "step_start": {
|
|
48
|
+
if (event.sessionID) {
|
|
49
|
+
result.session.id = event.sessionID;
|
|
50
|
+
}
|
|
51
|
+
if (part?.messageID) {
|
|
52
|
+
result.session.messageId = part.messageID;
|
|
53
|
+
}
|
|
54
|
+
if (part?.snapshot) {
|
|
55
|
+
result.gitSnapshot = part.snapshot;
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case "tool_use": {
|
|
60
|
+
result.tools.count++;
|
|
61
|
+
const toolName = part?.tool;
|
|
62
|
+
if (toolName && !result.tools.names.includes(toolName)) {
|
|
63
|
+
result.tools.names.push(toolName);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "text": {
|
|
68
|
+
// Track text events; we'll capture the last one before step_finish(reason=stop)
|
|
69
|
+
const textContent = part?.text;
|
|
70
|
+
if (textContent) {
|
|
71
|
+
lastTextBeforeStop = textContent;
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "step_finish": {
|
|
76
|
+
if (part?.reason === "stop") {
|
|
77
|
+
sawStepFinishStop = true;
|
|
78
|
+
// Capture the text accumulated so far
|
|
79
|
+
result.text = lastTextBeforeStop;
|
|
80
|
+
lastTextBeforeStop = null;
|
|
81
|
+
}
|
|
82
|
+
// Accumulate tokens and cost
|
|
83
|
+
const tokens = part?.tokens;
|
|
84
|
+
if (tokens) {
|
|
85
|
+
if (typeof tokens.input === "number")
|
|
86
|
+
result.tokens.input += tokens.input;
|
|
87
|
+
if (typeof tokens.output === "number")
|
|
88
|
+
result.tokens.output += tokens.output;
|
|
89
|
+
if (typeof tokens.reasoning === "number")
|
|
90
|
+
result.tokens.reasoning += tokens.reasoning;
|
|
91
|
+
const cache = tokens.cache;
|
|
92
|
+
if (cache) {
|
|
93
|
+
if (typeof cache.read === "number")
|
|
94
|
+
result.tokens.cache.read += cache.read;
|
|
95
|
+
if (typeof cache.write === "number")
|
|
96
|
+
result.tokens.cache.write += cache.write;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (typeof part?.cost === "number") {
|
|
100
|
+
result.cost += part.cost;
|
|
101
|
+
}
|
|
102
|
+
if (part?.snapshot) {
|
|
103
|
+
result.gitSnapshot = part.snapshot;
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "error": {
|
|
108
|
+
const error = event.error;
|
|
109
|
+
if (error) {
|
|
110
|
+
const name = error.name;
|
|
111
|
+
const data = error.data;
|
|
112
|
+
result.error = {
|
|
113
|
+
name: name ?? "UnknownError",
|
|
114
|
+
message: data?.message ?? "Unknown error",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// If we never saw a step_finish with reason=stop, don't set text
|
|
122
|
+
if (!sawStepFinishStop) {
|
|
123
|
+
result.text = null;
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
@@ -10,11 +10,27 @@ 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
|
|
27
|
+
return input.replace(/{{([\w.]+)}}/g, (_, key) => {
|
|
28
|
+
const raw = resolveNestedValue(context, key);
|
|
16
29
|
if (raw === undefined || raw === null)
|
|
17
30
|
return "";
|
|
31
|
+
if (typeof raw === "object") {
|
|
32
|
+
return shellEscape(JSON.stringify(raw, null, 2));
|
|
33
|
+
}
|
|
18
34
|
return shellEscape(String(raw));
|
|
19
35
|
});
|
|
20
36
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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) {
|
|
@@ -130,7 +131,10 @@ export function executeChain(options) {
|
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
133
|
if (shouldCaptureStdout && (stepStdout || stepStderr)) {
|
|
133
|
-
|
|
134
|
+
const opencodeCtx = stepResults
|
|
135
|
+
.filter((r) => r.status === "fulfilled")
|
|
136
|
+
.find((r) => r.value.opencode)?.value?.opencode;
|
|
137
|
+
mergeCommandOutput(chainContext, stepStdout, stepStderr, opencodeCtx);
|
|
134
138
|
}
|
|
135
139
|
const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
|
|
136
140
|
if (stepFailure) {
|
|
@@ -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;
|
|
@@ -98,7 +99,11 @@ export async function executeRunImpl(ctrl, signal) {
|
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
if (shouldCaptureStdout && (stepStdout || stepStderr)) {
|
|
101
|
-
|
|
102
|
+
// Extract opencode context from the first fulfilled step result
|
|
103
|
+
const opencodeCtx = stepResults
|
|
104
|
+
.filter((r) => r.status === "fulfilled")
|
|
105
|
+
.find((r) => r.value.opencode)?.value?.opencode;
|
|
106
|
+
mergeCommandOutput(chainContext, stepStdout, stepStderr, opencodeCtx);
|
|
102
107
|
}
|
|
103
108
|
const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
|
|
104
109
|
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);
|
|
@@ -50,23 +52,19 @@ async function main() {
|
|
|
50
52
|
const httpPort = parseInt(process.env.LOOP_CLI_HTTP_PORT ?? "", 10);
|
|
51
53
|
const resolvedHttpPort = Number.isNaN(httpPort) ? undefined : httpPort;
|
|
52
54
|
let currentHttpHost = settingsManager.get().httpApiHost;
|
|
55
|
+
const startupTasks = [];
|
|
53
56
|
if (settingsManager.get().httpApiEnabled) {
|
|
54
|
-
|
|
55
|
-
await httpServer.listen(resolvedHttpPort, currentHttpHost);
|
|
56
|
-
}
|
|
57
|
-
catch (err) {
|
|
57
|
+
startupTasks.push(httpServer.listen(resolvedHttpPort, currentHttpHost).catch((err) => {
|
|
58
58
|
daemonLog(`HTTP API server failed to start: ${String(err)}`);
|
|
59
|
-
}
|
|
59
|
+
}));
|
|
60
60
|
}
|
|
61
61
|
mcpServer.setHost(currentHttpHost);
|
|
62
62
|
if (mcpEnabled) {
|
|
63
|
-
|
|
64
|
-
await mcpServer.start();
|
|
65
|
-
}
|
|
66
|
-
catch (err) {
|
|
63
|
+
startupTasks.push(mcpServer.start().catch((err) => {
|
|
67
64
|
daemonLog(`MCP server failed to start: ${String(err)}`);
|
|
68
|
-
}
|
|
65
|
+
}));
|
|
69
66
|
}
|
|
67
|
+
await Promise.all(startupTasks);
|
|
70
68
|
settingsManager.onChange((settings) => {
|
|
71
69
|
const newHost = settings.httpApiHost;
|
|
72
70
|
const hostChanged = newHost !== currentHttpHost;
|
|
@@ -150,6 +148,12 @@ async function main() {
|
|
|
150
148
|
// Reconcile after watchers are attached so files created during startup cannot be missed.
|
|
151
149
|
recipeScanner.scanAllProjects();
|
|
152
150
|
daemonLog(`recipe scanner initialized`);
|
|
151
|
+
// Start agent serve sidecars (e.g., opencode serve) after core services are up
|
|
152
|
+
if (settingsManager.get().telemetryEnabled) {
|
|
153
|
+
agentServeManager.ensureAllServe(process.cwd()).catch((err) => {
|
|
154
|
+
daemonLog(`agent serve startup failed: ${String(err)}`);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
153
157
|
let shuttingDown = false;
|
|
154
158
|
const cleanup = async () => {
|
|
155
159
|
if (shuttingDown)
|
|
@@ -158,6 +162,7 @@ async function main() {
|
|
|
158
162
|
daemonLog(`shutting down pid=${process.pid}`);
|
|
159
163
|
try {
|
|
160
164
|
killAllActiveProcesses();
|
|
165
|
+
await agentServeManager.shutdownAll();
|
|
161
166
|
await telemetryManager.shutdown();
|
|
162
167
|
fileWatcher.stop();
|
|
163
168
|
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() {
|
|
@@ -1,11 +1,24 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
import { daemonLog } from "../../daemon-log.js";
|
|
1
3
|
const OPENCODE_BINARIES = ["opencode"];
|
|
4
|
+
const SERVE_PORT = 4096;
|
|
5
|
+
const SERVE_URL = `http://localhost:${SERVE_PORT}`;
|
|
6
|
+
const HEALTH_CHECK_TIMEOUT_MS = 120_000;
|
|
7
|
+
const HEALTH_CHECK_INTERVAL_MS = 1_000;
|
|
8
|
+
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
2
9
|
/**
|
|
3
10
|
* Detects `opencode run ...` invocations and enables OpenCode's
|
|
4
11
|
* native OpenTelemetry support, routing to loop-task's endpoint.
|
|
12
|
+
*
|
|
13
|
+
* Also manages a persistent `opencode serve` sidecar process so that
|
|
14
|
+
* `opencode run` tasks delegate to a warm serve instance via `--attach`,
|
|
15
|
+
* eliminating per-task cold-start time.
|
|
5
16
|
*/
|
|
6
17
|
export class OpenCodeTelemetryIntegration {
|
|
7
18
|
constructor() {
|
|
8
19
|
this.id = "opencode";
|
|
20
|
+
this.serveProcess = null;
|
|
21
|
+
this.serveInfo = null;
|
|
9
22
|
}
|
|
10
23
|
matches(command, args) {
|
|
11
24
|
const basename = command.split("/").pop()?.replace(/\.exe$/, "").toLowerCase() ?? "";
|
|
@@ -15,19 +28,53 @@ export class OpenCodeTelemetryIntegration {
|
|
|
15
28
|
const env = { ...invocation.env };
|
|
16
29
|
// Enable OpenCode's experimental OpenTelemetry
|
|
17
30
|
env.OPENCODE_EXPERIMENTAL_OPEN_TELEMETRY = "true";
|
|
18
|
-
// Adapt the OTLP endpoint/proprotocol from the parent context
|
|
19
|
-
// OpenCode currently reads the standard OTEL_* environment variables
|
|
20
|
-
// which are already set by the adapter's prepareChildProcess
|
|
21
|
-
// We just need to make sure the integration is activated.
|
|
22
31
|
return { command: invocation.command, args: [...invocation.args], env };
|
|
23
32
|
}
|
|
24
33
|
parseUsage(result) {
|
|
25
34
|
if (result.exitCode !== 0 || !result.stdout)
|
|
26
35
|
return undefined;
|
|
27
36
|
try {
|
|
28
|
-
//
|
|
29
|
-
//
|
|
37
|
+
// With --format json, opencode emits JSONL. The last step_finish event
|
|
38
|
+
// has token/cost info. Try parsing as JSONL first.
|
|
30
39
|
const lines = result.stdout.trim().split("\n");
|
|
40
|
+
const jsonlLines = [];
|
|
41
|
+
let allJson = true;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
try {
|
|
44
|
+
jsonlLines.push(JSON.parse(line));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
allJson = false;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (allJson && jsonlLines.length > 0) {
|
|
52
|
+
// Find the last step_finish event
|
|
53
|
+
let accumulated = {};
|
|
54
|
+
for (const evt of jsonlLines) {
|
|
55
|
+
if (typeof evt === "object" && evt !== null && evt.type === "step_finish") {
|
|
56
|
+
const part = evt.part;
|
|
57
|
+
const tokens = part?.tokens;
|
|
58
|
+
if (tokens) {
|
|
59
|
+
accumulated.inputTokens = (accumulated.inputTokens ?? 0) + (typeof tokens.input === "number" ? tokens.input : 0);
|
|
60
|
+
accumulated.outputTokens = (accumulated.outputTokens ?? 0) + (typeof tokens.output === "number" ? tokens.output : 0);
|
|
61
|
+
const cache = tokens.cache;
|
|
62
|
+
if (cache) {
|
|
63
|
+
accumulated.cacheReadTokens = (accumulated.cacheReadTokens ?? 0) + (typeof cache.read === "number" ? cache.read : 0);
|
|
64
|
+
accumulated.cacheWriteTokens = (accumulated.cacheWriteTokens ?? 0) + (typeof cache.write === "number" ? cache.write : 0);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (typeof part?.cost === "number") {
|
|
68
|
+
accumulated.costUsd = (accumulated.costUsd ?? 0) + part.cost;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (Object.keys(accumulated).length > 0) {
|
|
73
|
+
accumulated.integration = "opencode";
|
|
74
|
+
return accumulated;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Fallback: try parsing the last line as JSON (legacy --format json without JSONL)
|
|
31
78
|
const lastLine = lines[lines.length - 1];
|
|
32
79
|
if (!lastLine)
|
|
33
80
|
return undefined;
|
|
@@ -63,10 +110,133 @@ export class OpenCodeTelemetryIntegration {
|
|
|
63
110
|
usage.sessionId = parsed.sessionId;
|
|
64
111
|
if (Object.keys(usage).length === 0)
|
|
65
112
|
return undefined;
|
|
113
|
+
usage.integration = "opencode";
|
|
66
114
|
return usage;
|
|
67
115
|
}
|
|
68
116
|
catch {
|
|
69
117
|
return undefined;
|
|
70
118
|
}
|
|
71
119
|
}
|
|
120
|
+
async ensureServe(cwd, env) {
|
|
121
|
+
// Already running and healthy
|
|
122
|
+
if (this.serveInfo && this.isServeAlive()) {
|
|
123
|
+
return this.serveInfo;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
daemonLog("opencode-serve: starting opencode serve sidecar");
|
|
127
|
+
this.serveProcess = execa("opencode", [
|
|
128
|
+
"serve",
|
|
129
|
+
"--port", String(SERVE_PORT),
|
|
130
|
+
"--hostname", "127.0.0.1",
|
|
131
|
+
], {
|
|
132
|
+
cwd,
|
|
133
|
+
env: { ...process.env, ...env },
|
|
134
|
+
stdout: "pipe",
|
|
135
|
+
stderr: "pipe",
|
|
136
|
+
buffer: false,
|
|
137
|
+
detached: false,
|
|
138
|
+
});
|
|
139
|
+
this.serveProcess.catch((err) => {
|
|
140
|
+
daemonLog(`opencode-serve: process error: ${String(err)}`);
|
|
141
|
+
});
|
|
142
|
+
this.serveProcess.on("exit", (code) => {
|
|
143
|
+
daemonLog(`opencode-serve: process exited with code ${code}`);
|
|
144
|
+
if (code !== 0 && code !== null) {
|
|
145
|
+
this.serveProcess = null;
|
|
146
|
+
this.serveInfo = null;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
// Wait for health check
|
|
150
|
+
const healthy = await this.waitForHealth();
|
|
151
|
+
if (healthy) {
|
|
152
|
+
const pid = this.serveProcess?.pid;
|
|
153
|
+
if (pid) {
|
|
154
|
+
this.serveInfo = { port: SERVE_PORT, pid, url: SERVE_URL };
|
|
155
|
+
daemonLog(`opencode-serve: healthy, pid=${pid}, port=${SERVE_PORT}`);
|
|
156
|
+
return this.serveInfo;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
daemonLog("opencode-serve: health check timed out");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
daemonLog(`opencode-serve: failed to start: ${String(err)}`);
|
|
164
|
+
this.serveProcess = null;
|
|
165
|
+
this.serveInfo = null;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
isServeAlive() {
|
|
170
|
+
if (!this.serveInfo)
|
|
171
|
+
return false;
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
prepareRunArgs(args, cwd) {
|
|
175
|
+
const modified = [...args];
|
|
176
|
+
// Inject --attach after "run" (args[0] === "run")
|
|
177
|
+
const runIndex = modified.indexOf("run");
|
|
178
|
+
if (runIndex === -1)
|
|
179
|
+
return modified;
|
|
180
|
+
// Check if --attach is already present
|
|
181
|
+
const hasAttach = modified.includes("--attach");
|
|
182
|
+
if (!hasAttach) {
|
|
183
|
+
modified.splice(runIndex + 1, 0, "--attach", SERVE_URL);
|
|
184
|
+
}
|
|
185
|
+
// Inject --dir so serve knows which directory to work in
|
|
186
|
+
const hasDir = modified.includes("--dir");
|
|
187
|
+
if (!hasDir && cwd) {
|
|
188
|
+
// Find the position after --attach (or after "run" if --attach wasn't added by us)
|
|
189
|
+
const attachIdx = modified.indexOf("--attach");
|
|
190
|
+
const insertPos = attachIdx >= 0 ? attachIdx + 2 : runIndex + 1;
|
|
191
|
+
modified.splice(insertPos, 0, "--dir", cwd);
|
|
192
|
+
}
|
|
193
|
+
return modified;
|
|
194
|
+
}
|
|
195
|
+
async shutdownServe() {
|
|
196
|
+
if (!this.serveProcess)
|
|
197
|
+
return;
|
|
198
|
+
daemonLog("opencode-serve: shutting down serve process");
|
|
199
|
+
const proc = this.serveProcess;
|
|
200
|
+
this.serveProcess = null;
|
|
201
|
+
this.serveInfo = null;
|
|
202
|
+
try {
|
|
203
|
+
proc.kill("SIGTERM");
|
|
204
|
+
await Promise.race([
|
|
205
|
+
proc,
|
|
206
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("shutdown timeout")), SHUTDOWN_TIMEOUT_MS)),
|
|
207
|
+
]);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Force kill if SIGTERM didn't work
|
|
211
|
+
try {
|
|
212
|
+
proc.kill("SIGKILL");
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// best effort
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async waitForHealth() {
|
|
220
|
+
const start = Date.now();
|
|
221
|
+
while (Date.now() - start < HEALTH_CHECK_TIMEOUT_MS) {
|
|
222
|
+
if (!this.serveProcess)
|
|
223
|
+
return false; // process exited
|
|
224
|
+
try {
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
const timeout = setTimeout(() => controller.abort(), 3_000);
|
|
227
|
+
const res = await fetch(`${SERVE_URL}/global/health`, { signal: controller.signal });
|
|
228
|
+
clearTimeout(timeout);
|
|
229
|
+
if (res.ok) {
|
|
230
|
+
const data = await res.json();
|
|
231
|
+
if (data.healthy === true)
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// server not ready yet
|
|
237
|
+
}
|
|
238
|
+
await new Promise((r) => setTimeout(r, HEALTH_CHECK_INTERVAL_MS));
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
72
242
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { daemonLog } from "../daemon-log.js";
|
|
2
|
+
import { getAgentIntegrations } from "./agent-integrations/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Manages the lifecycle of persistent agent serve sidecars (e.g., opencode serve).
|
|
5
|
+
*
|
|
6
|
+
* On daemon boot, starts serve processes for integrations that support them.
|
|
7
|
+
* On daemon shutdown, gracefully stops all serve processes.
|
|
8
|
+
*
|
|
9
|
+
* The serve manager is the single owner of serve process lifecycle.
|
|
10
|
+
* The TelemetryManager and command-runner reference the integration's
|
|
11
|
+
* isServeAlive() to check state, but never start/stop serve processes directly.
|
|
12
|
+
*/
|
|
13
|
+
export class AgentServeManager {
|
|
14
|
+
constructor(settings, telemetryManager) {
|
|
15
|
+
this.settings = settings;
|
|
16
|
+
this.telemetryManager = telemetryManager;
|
|
17
|
+
this.integrations = [];
|
|
18
|
+
this.integrations = [...getAgentIntegrations()];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Start serve sidecars for all integrations that support them.
|
|
22
|
+
* Called on daemon boot.
|
|
23
|
+
*/
|
|
24
|
+
async ensureAllServe(cwd) {
|
|
25
|
+
const endpoint = this.settings.telemetryEndpoint;
|
|
26
|
+
if (!endpoint) {
|
|
27
|
+
daemonLog("agent-serve-manager: no telemetry endpoint, skipping serve startup");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Build static OTEL env for serve processes
|
|
31
|
+
const serveEnv = {
|
|
32
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
|
|
33
|
+
OTEL_EXPORTER_OTLP_PROTOCOL: this.settings.telemetryProtocol,
|
|
34
|
+
OTEL_TRACES_EXPORTER: "otlp",
|
|
35
|
+
OTEL_METRICS_EXPORTER: "otlp",
|
|
36
|
+
OTEL_LOGS_EXPORTER: "otlp",
|
|
37
|
+
OTEL_RESOURCE_ATTRIBUTES: `service.name=${this.settings.telemetryServiceName}`,
|
|
38
|
+
};
|
|
39
|
+
if (this.settings.telemetryAutoInstrumentAgents) {
|
|
40
|
+
serveEnv.OPENCODE_EXPERIMENTAL_OPEN_TELEMETRY = "true";
|
|
41
|
+
serveEnv.CLAUDE_CODE_ENABLE_TELEMETRY = "1";
|
|
42
|
+
}
|
|
43
|
+
for (const integration of this.integrations) {
|
|
44
|
+
if (!integration.ensureServe)
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
daemonLog(`agent-serve-manager: ensuring serve for integration "${integration.id}"`);
|
|
48
|
+
const info = await integration.ensureServe(cwd, serveEnv);
|
|
49
|
+
if (info) {
|
|
50
|
+
daemonLog(`agent-serve-manager: serve started for "${integration.id}", pid=${info.pid}, port=${info.port}`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
daemonLog(`agent-serve-manager: serve not started for "${integration.id}" (not supported or failed)`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
daemonLog(`agent-serve-manager: failed to start serve for "${integration.id}": ${String(err)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Gracefully shut down all managed serve processes.
|
|
63
|
+
* Called on daemon shutdown.
|
|
64
|
+
*/
|
|
65
|
+
async shutdownAll() {
|
|
66
|
+
for (const integration of this.integrations) {
|
|
67
|
+
if (!integration.shutdownServe)
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
daemonLog(`agent-serve-manager: shutting down serve for "${integration.id}"`);
|
|
71
|
+
await integration.shutdownServe();
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
daemonLog(`agent-serve-manager: error shutting down "${integration.id}": ${String(err)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|