@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.
@@ -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[key];
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
- mergeCommandOutput(chainContext, stepStdout, stepStderr);
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
- mergeCommandOutput(chainContext, stepStdout, stepStderr);
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. 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);
@@ -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
- try {
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
- try {
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. 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() {
@@ -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
- // OpenCode may output JSON with --format json
29
- // Try to parse structured output for usage data
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
+ }
@@ -39,6 +39,7 @@ export class NoopTelemetryAdapter {
39
39
  recordRetry() { }
40
40
  recordFailure() { }
41
41
  recordAgentUsage() { }
42
+ logEvent() { }
42
43
  prepareChildProcess(_invocation, _context, _integrationOverride) {
43
44
  return { env: {} };
44
45
  }