@calltelemetry/openclaw-linear 0.5.1 → 0.6.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.
Files changed (35) hide show
  1. package/README.md +359 -195
  2. package/index.ts +10 -10
  3. package/openclaw.plugin.json +4 -1
  4. package/package.json +9 -2
  5. package/src/agent/agent.test.ts +127 -0
  6. package/src/{agent.ts → agent/agent.ts} +84 -7
  7. package/src/agent/watchdog.test.ts +266 -0
  8. package/src/agent/watchdog.ts +176 -0
  9. package/src/{cli.ts → infra/cli.ts} +5 -5
  10. package/src/{codex-worktree.ts → infra/codex-worktree.ts} +4 -0
  11. package/src/infra/notify.test.ts +169 -0
  12. package/src/{notify.ts → infra/notify.ts} +6 -1
  13. package/src/pipeline/active-session.test.ts +154 -0
  14. package/src/pipeline/artifacts.test.ts +383 -0
  15. package/src/pipeline/artifacts.ts +273 -0
  16. package/src/{dispatch-service.ts → pipeline/dispatch-service.ts} +1 -1
  17. package/src/pipeline/dispatch-state.test.ts +382 -0
  18. package/src/{dispatch-state.ts → pipeline/dispatch-state.ts} +1 -0
  19. package/src/pipeline/pipeline.test.ts +226 -0
  20. package/src/{pipeline.ts → pipeline/pipeline.ts} +134 -10
  21. package/src/{tier-assess.ts → pipeline/tier-assess.ts} +1 -1
  22. package/src/{webhook.test.ts → pipeline/webhook.test.ts} +1 -1
  23. package/src/{webhook.ts → pipeline/webhook.ts} +30 -8
  24. package/src/{claude-tool.ts → tools/claude-tool.ts} +31 -5
  25. package/src/{cli-shared.ts → tools/cli-shared.ts} +5 -4
  26. package/src/{code-tool.ts → tools/code-tool.ts} +2 -2
  27. package/src/{codex-tool.ts → tools/codex-tool.ts} +31 -5
  28. package/src/{gemini-tool.ts → tools/gemini-tool.ts} +31 -5
  29. package/src/{orchestration-tools.ts → tools/orchestration-tools.ts} +1 -1
  30. package/src/client.ts +0 -94
  31. /package/src/{auth.ts → api/auth.ts} +0 -0
  32. /package/src/{linear-api.ts → api/linear-api.ts} +0 -0
  33. /package/src/{oauth-callback.ts → api/oauth-callback.ts} +0 -0
  34. /package/src/{active-session.ts → pipeline/active-session.ts} +0 -0
  35. /package/src/{tools.ts → tools/tools.ts} +0 -0
@@ -16,8 +16,8 @@ import { join, dirname } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { parse as parseYaml } from "yaml";
18
18
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
19
- import type { LinearAgentApi, ActivityContent } from "./linear-api.js";
20
- import { runAgent } from "./agent.js";
19
+ import type { LinearAgentApi, ActivityContent } from "../api/linear-api.js";
20
+ import { runAgent } from "../agent/agent.js";
21
21
  import { setActiveSession, clearActiveSession } from "./active-session.js";
22
22
  import {
23
23
  type Tier,
@@ -33,7 +33,18 @@ import {
33
33
  readDispatchState,
34
34
  getActiveDispatch,
35
35
  } from "./dispatch-state.js";
36
- import { type NotifyFn } from "./notify.js";
36
+ import { type NotifyFn } from "../infra/notify.js";
37
+ import {
38
+ saveWorkerOutput,
39
+ saveAuditVerdict,
40
+ appendLog,
41
+ updateManifest,
42
+ writeSummary,
43
+ buildSummaryFromArtifacts,
44
+ writeDispatchMemory,
45
+ resolveOrchestratorWorkspace,
46
+ } from "./artifacts.js";
47
+ import { resolveWatchdogConfig } from "../agent/watchdog.js";
37
48
 
38
49
  // ---------------------------------------------------------------------------
39
50
  // Prompt loading
@@ -76,7 +87,7 @@ export function loadPrompts(pluginConfig?: Record<string, unknown>): PromptTempl
76
87
  raw = readFileSync(resolved, "utf-8");
77
88
  } else {
78
89
  // Load from plugin directory (sidecar file)
79
- const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
90
+ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
80
91
  raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
81
92
  }
82
93
 
@@ -264,6 +275,9 @@ export async function triggerAudit(
264
275
 
265
276
  api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
266
277
 
278
+ // Update .claw/ manifest
279
+ try { updateManifest(dispatch.worktreePath, { status: "auditing", attempts: dispatch.attempt }); } catch {}
280
+
267
281
  // Fetch fresh issue details for audit context
268
282
  const issueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
269
283
  const issue: IssueContext = {
@@ -313,7 +327,9 @@ export async function triggerAudit(
313
327
  agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
314
328
  sessionId: auditSessionId,
315
329
  message: `${auditPrompt.system}\n\n${auditPrompt.task}`,
316
- timeoutMs: 5 * 60_000,
330
+ streaming: dispatch.agentSessionId
331
+ ? { linearApi, agentSessionId: dispatch.agentSessionId }
332
+ : undefined,
317
333
  });
318
334
 
319
335
  // runAgent returns inline (embedded runner) — process verdict directly.
@@ -371,6 +387,15 @@ export async function processVerdict(
371
387
  }
372
388
  }
373
389
 
390
+ // Log audit interaction to .claw/
391
+ try {
392
+ appendLog(dispatch.worktreePath, {
393
+ ts: new Date().toISOString(), phase: "audit", attempt: dispatch.attempt,
394
+ agent: "auditor", prompt: "(audit task)",
395
+ outputPreview: auditOutput.slice(0, 500), success: event.success,
396
+ });
397
+ } catch {}
398
+
374
399
  // Parse verdict
375
400
  const verdict = parseVerdict(auditOutput);
376
401
  if (!verdict) {
@@ -406,9 +431,13 @@ async function handleAuditPass(
406
431
  dispatch: ActiveDispatch,
407
432
  verdict: AuditVerdict,
408
433
  ): Promise<void> {
409
- const { api, linearApi, notify, configPath } = hookCtx;
434
+ const { api, linearApi, notify, pluginConfig, configPath } = hookCtx;
410
435
  const TAG = `[${dispatch.issueIdentifier}]`;
411
436
 
437
+ // Save audit verdict to .claw/
438
+ try { saveAuditVerdict(dispatch.worktreePath, dispatch.attempt, verdict); } catch {}
439
+ try { updateManifest(dispatch.worktreePath, { status: "done", attempts: dispatch.attempt + 1 }); } catch {}
440
+
412
441
  // CAS transition: auditing → done
413
442
  try {
414
443
  await transitionDispatch(dispatch.issueIdentifier, "auditing", "done", undefined, configPath);
@@ -428,11 +457,26 @@ async function handleAuditPass(
428
457
  project: dispatch.project,
429
458
  }, configPath);
430
459
 
431
- // Post approval comment
460
+ // Build summary from .claw/ artifacts and write to memory
461
+ let summary: string | null = null;
462
+ try {
463
+ summary = buildSummaryFromArtifacts(dispatch.worktreePath);
464
+ if (summary) {
465
+ writeSummary(dispatch.worktreePath, summary);
466
+ const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
467
+ writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir);
468
+ api.logger.info(`${TAG} .claw/ summary and memory written`);
469
+ }
470
+ } catch (err) {
471
+ api.logger.warn(`${TAG} failed to write summary/memory: ${err}`);
472
+ }
473
+
474
+ // Post approval comment (with summary excerpt if available)
432
475
  const criteriaList = verdict.criteria.map((c) => `- ${c}`).join("\n");
476
+ const summaryExcerpt = summary ? `\n\n**Summary:**\n${summary.slice(0, 2000)}` : "";
433
477
  await linearApi.createComment(
434
478
  dispatch.issueId,
435
- `## Audit Passed\n\n**Criteria verified:**\n${criteriaList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Attempt ${dispatch.attempt + 1} — audit passed.*`,
479
+ `## Audit Passed\n\n**Criteria verified:**\n${criteriaList}\n\n**Tests:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Attempt ${dispatch.attempt + 1} — audit passed. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
436
480
  ).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
437
481
 
438
482
  api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
@@ -458,8 +502,13 @@ async function handleAuditFail(
458
502
  const maxAttempts = (pluginConfig?.maxReworkAttempts as number) ?? 2;
459
503
  const nextAttempt = dispatch.attempt + 1;
460
504
 
505
+ // Save audit verdict to .claw/ (both escalation and rework paths)
506
+ try { saveAuditVerdict(dispatch.worktreePath, dispatch.attempt, verdict); } catch {}
507
+
461
508
  if (nextAttempt > maxAttempts) {
462
509
  // Escalate — too many failures
510
+ try { updateManifest(dispatch.worktreePath, { status: "stuck", attempts: nextAttempt }); } catch {}
511
+
463
512
  try {
464
513
  await transitionDispatch(
465
514
  dispatch.issueIdentifier,
@@ -476,10 +525,20 @@ async function handleAuditFail(
476
525
  throw err;
477
526
  }
478
527
 
528
+ // Write summary + memory for stuck dispatches too
529
+ try {
530
+ const summary = buildSummaryFromArtifacts(dispatch.worktreePath);
531
+ if (summary) {
532
+ writeSummary(dispatch.worktreePath, summary);
533
+ const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
534
+ writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir);
535
+ }
536
+ } catch {}
537
+
479
538
  const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
480
539
  await linearApi.createComment(
481
540
  dispatch.issueId,
482
- `## Audit Failed — Escalating\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}**\n\n**Gaps:**\n${gapsList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Max rework attempts reached. Needs human review.*`,
541
+ `## Audit Failed — Escalating\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}**\n\n**Gaps:**\n${gapsList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Max rework attempts reached. Needs human review. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
483
542
  ).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
484
543
 
485
544
  api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
@@ -608,14 +667,79 @@ export async function spawnWorker(
608
667
 
609
668
  api.logger.info(`${TAG} spawning worker agent session=${workerSessionId} (attempt ${dispatch.attempt})`);
610
669
 
670
+ const workerStartTime = Date.now();
611
671
  const result = await runAgent({
612
672
  api,
613
673
  agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
614
674
  sessionId: workerSessionId,
615
675
  message: `${workerPrompt.system}\n\n${workerPrompt.task}`,
616
- timeoutMs: (pluginConfig?.codexTimeoutMs as number) ?? 10 * 60_000,
676
+ streaming: dispatch.agentSessionId
677
+ ? { linearApi, agentSessionId: dispatch.agentSessionId }
678
+ : undefined,
617
679
  });
618
680
 
681
+ // Save worker output to .claw/
682
+ const workerElapsed = Date.now() - workerStartTime;
683
+ const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
684
+ try { saveWorkerOutput(dispatch.worktreePath, dispatch.attempt, result.output); } catch {}
685
+ try {
686
+ appendLog(dispatch.worktreePath, {
687
+ ts: new Date().toISOString(), phase: "worker", attempt: dispatch.attempt,
688
+ agent: agentId,
689
+ prompt: workerPrompt.task.slice(0, 200),
690
+ outputPreview: result.output.slice(0, 500),
691
+ success: result.success, durationMs: workerElapsed,
692
+ });
693
+ } catch {}
694
+
695
+ // Handle watchdog kill (runAgent already retried once — both attempts failed)
696
+ if (result.watchdogKilled) {
697
+ const wdConfig = resolveWatchdogConfig(agentId, pluginConfig ?? undefined);
698
+ const thresholdSec = Math.round(wdConfig.inactivityMs / 1000);
699
+
700
+ api.logger.warn(`${TAG} worker killed by inactivity watchdog 2x — escalating to stuck`);
701
+
702
+ try {
703
+ appendLog(dispatch.worktreePath, {
704
+ ts: new Date().toISOString(), phase: "watchdog", attempt: dispatch.attempt,
705
+ agent: agentId, prompt: "(watchdog kill)",
706
+ outputPreview: result.output.slice(0, 500), success: false,
707
+ durationMs: workerElapsed,
708
+ watchdog: { reason: "inactivity", silenceSec: thresholdSec, thresholdSec, retried: true },
709
+ });
710
+ } catch {}
711
+
712
+ try { updateManifest(dispatch.worktreePath, { status: "stuck", attempts: dispatch.attempt + 1 }); } catch {}
713
+
714
+ try {
715
+ await transitionDispatch(
716
+ dispatch.issueIdentifier, "working", "stuck",
717
+ { stuckReason: "watchdog_kill_2x" }, configPath,
718
+ );
719
+ } catch (err) {
720
+ if (err instanceof TransitionError) {
721
+ api.logger.warn(`${TAG} CAS failed for watchdog stuck transition: ${err.message}`);
722
+ }
723
+ }
724
+
725
+ await linearApi.createComment(
726
+ dispatch.issueId,
727
+ `## Watchdog Kill\n\nAgent killed by inactivity watchdog (no I/O for ${thresholdSec}s). ` +
728
+ `Automatic retry also failed.\n\n---\n*Needs human review. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
729
+ ).catch(() => {});
730
+
731
+ await hookCtx.notify("watchdog_kill", {
732
+ identifier: dispatch.issueIdentifier,
733
+ title: issue.title,
734
+ status: "stuck",
735
+ attempt: dispatch.attempt,
736
+ reason: `no I/O for ${thresholdSec}s`,
737
+ });
738
+
739
+ clearActiveSession(dispatch.issueId);
740
+ return;
741
+ }
742
+
619
743
  // runAgent returns inline — trigger audit directly.
620
744
  // Re-read dispatch state since it may have changed during worker run.
621
745
  const freshState = await readDispatchState(configPath);
@@ -75,7 +75,7 @@ export async function assessTier(
75
75
  const message = `${ASSESS_PROMPT}\n\n${issueText}`;
76
76
 
77
77
  try {
78
- const { runAgent } = await import("./agent.js");
78
+ const { runAgent } = await import("../agent/agent.js");
79
79
  const result = await runAgent({
80
80
  api,
81
81
  agentId: agentId ?? resolveDefaultAgent(api),
@@ -17,7 +17,7 @@ vi.mock("./pipeline.js", () => ({
17
17
  }));
18
18
 
19
19
  // Mock the linear-api module
20
- vi.mock("./linear-api.js", () => ({
20
+ vi.mock("../api/linear-api.js", () => ({
21
21
  LinearAgentApi: class MockLinearAgentApi {
22
22
  emitActivity = vi.fn().mockResolvedValue(undefined);
23
23
  createComment = vi.fn().mockResolvedValue("comment-id");
@@ -2,13 +2,14 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
5
- import { LinearAgentApi, resolveLinearToken } from "./linear-api.js";
5
+ import { LinearAgentApi, resolveLinearToken } from "../api/linear-api.js";
6
6
  import { spawnWorker, type HookContext } from "./pipeline.js";
7
7
  import { setActiveSession, clearActiveSession } from "./active-session.js";
8
8
  import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchStatus, completeDispatch, removeActiveDispatch } from "./dispatch-state.js";
9
- import { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "./notify.js";
9
+ import { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "../infra/notify.js";
10
10
  import { assessTier } from "./tier-assess.js";
11
- import { createWorktree, prepareWorkspace } from "./codex-worktree.js";
11
+ import { createWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
12
+ import { ensureClawDir, writeManifest } from "./artifacts.js";
12
13
 
13
14
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
14
15
  interface AgentProfile {
@@ -244,7 +245,7 @@ export async function handleLinearWebhook(
244
245
 
245
246
  // 3. Run agent with streaming
246
247
  const sessionId = `linear-notif-${notification?.type ?? "unknown"}-${Date.now()}`;
247
- const { runAgent } = await import("./agent.js");
248
+ const { runAgent } = await import("../agent/agent.js");
248
249
  const result = await runAgent({
249
250
  api,
250
251
  agentId,
@@ -417,7 +418,7 @@ export async function handleLinearWebhook(
417
418
 
418
419
  // Run agent with streaming to Linear
419
420
  const sessionId = `linear-session-${session.id}`;
420
- const { runAgent } = await import("./agent.js");
421
+ const { runAgent } = await import("../agent/agent.js");
421
422
  const result = await runAgent({
422
423
  api,
423
424
  agentId,
@@ -595,7 +596,7 @@ export async function handleLinearWebhook(
595
596
  }).catch(() => {});
596
597
 
597
598
  const sessionId = `linear-session-${session.id}`;
598
- const { runAgent } = await import("./agent.js");
599
+ const { runAgent } = await import("../agent/agent.js");
599
600
  const result = await runAgent({
600
601
  api,
601
602
  agentId,
@@ -797,7 +798,7 @@ export async function handleLinearWebhook(
797
798
 
798
799
  // 3. Run agent subprocess with streaming
799
800
  const sessionId = `linear-comment-${comment.id ?? Date.now()}`;
800
- const { runAgent } = await import("./agent.js");
801
+ const { runAgent } = await import("../agent/agent.js");
801
802
  const result = await runAgent({
802
803
  api,
803
804
  agentId: mentionedAgent,
@@ -1035,7 +1036,7 @@ export async function handleLinearWebhook(
1035
1036
  ].filter(Boolean).join("\n");
1036
1037
 
1037
1038
  const sessionId = `linear-triage-${issue.id}-${Date.now()}`;
1038
- const { runAgent } = await import("./agent.js");
1039
+ const { runAgent } = await import("../agent/agent.js");
1039
1040
  const result = await runAgent({
1040
1041
  api,
1041
1042
  agentId,
@@ -1250,11 +1251,32 @@ async function handleDispatch(
1250
1251
  api.logger.warn(`@dispatch: could not create agent session: ${err}`);
1251
1252
  }
1252
1253
 
1254
+ // 6b. Initialize .claw/ artifact directory
1255
+ try {
1256
+ ensureClawDir(worktree.path);
1257
+ writeManifest(worktree.path, {
1258
+ issueIdentifier: identifier,
1259
+ issueTitle: enrichedIssue.title ?? "(untitled)",
1260
+ issueId: issue.id,
1261
+ tier: assessment.tier,
1262
+ model: assessment.model,
1263
+ dispatchedAt: new Date().toISOString(),
1264
+ worktreePath: worktree.path,
1265
+ branch: worktree.branch,
1266
+ attempts: 0,
1267
+ status: "dispatched",
1268
+ plugin: "openclaw-linear",
1269
+ });
1270
+ } catch (err) {
1271
+ api.logger.warn(`@dispatch: .claw/ init failed: ${err}`);
1272
+ }
1273
+
1253
1274
  // 7. Register dispatch in persistent state
1254
1275
  const now = new Date().toISOString();
1255
1276
  await registerDispatch(identifier, {
1256
1277
  issueId: issue.id,
1257
1278
  issueIdentifier: identifier,
1279
+ issueTitle: enrichedIssue.title ?? "(untitled)",
1258
1280
  worktreePath: worktree.path,
1259
1281
  branch: worktree.branch,
1260
1282
  tier: assessment.tier,
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
4
- import type { ActivityContent } from "./linear-api.js";
4
+ import type { ActivityContent } from "../api/linear-api.js";
5
5
  import {
6
6
  buildLinearApi,
7
7
  resolveSession,
@@ -11,6 +11,7 @@ import {
11
11
  type CliToolParams,
12
12
  type CliResult,
13
13
  } from "./cli-shared.js";
14
+ import { InactivityWatchdog, resolveWatchdogConfig } from "../agent/watchdog.js";
14
15
 
15
16
  const CLAUDE_BIN = "/home/claw/.npm-global/bin/claude";
16
17
 
@@ -116,7 +117,9 @@ export async function runClaude(
116
117
 
117
118
  api.logger.info(`claude_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
118
119
 
119
- const timeout = timeoutMs ?? (pluginConfig?.claudeTimeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
120
+ const agentId = (params as any).agentId ?? (pluginConfig?.defaultAgentId as string) ?? "default";
121
+ const wdConfig = resolveWatchdogConfig(agentId, pluginConfig ?? undefined);
122
+ const timeout = timeoutMs ?? (pluginConfig?.claudeTimeoutMs as number) ?? wdConfig.toolTimeoutMs;
120
123
  const workingDir = params.workingDir ?? (pluginConfig?.claudeBaseRepo as string) ?? DEFAULT_BASE_REPO;
121
124
 
122
125
  const linearApi = buildLinearApi(api, agentSessionId);
@@ -155,12 +158,27 @@ export async function runClaude(
155
158
  });
156
159
 
157
160
  let killed = false;
161
+ let killedByWatchdog = false;
158
162
  const timer = setTimeout(() => {
159
163
  killed = true;
160
164
  child.kill("SIGTERM");
161
165
  setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5_000);
162
166
  }, timeout);
163
167
 
168
+ const watchdog = new InactivityWatchdog({
169
+ inactivityMs: wdConfig.inactivityMs,
170
+ label: `claude:${agentSessionId ?? "unknown"}`,
171
+ logger: api.logger,
172
+ onKill: () => {
173
+ killedByWatchdog = true;
174
+ killed = true;
175
+ clearTimeout(timer);
176
+ child.kill("SIGTERM");
177
+ setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5_000);
178
+ },
179
+ });
180
+ watchdog.start();
181
+
164
182
  const collectedMessages: string[] = [];
165
183
  const collectedCommands: string[] = [];
166
184
  let stderrOutput = "";
@@ -169,6 +187,7 @@ export async function runClaude(
169
187
  const rl = createInterface({ input: child.stdout! });
170
188
  rl.on("line", (line) => {
171
189
  if (!line.trim()) return;
190
+ watchdog.tick();
172
191
 
173
192
  let event: any;
174
193
  try {
@@ -229,11 +248,13 @@ export async function runClaude(
229
248
  });
230
249
 
231
250
  child.stderr?.on("data", (chunk) => {
251
+ watchdog.tick();
232
252
  stderrOutput += chunk.toString();
233
253
  });
234
254
 
235
255
  child.on("close", (code) => {
236
256
  clearTimeout(timer);
257
+ watchdog.stop();
237
258
  rl.close();
238
259
 
239
260
  const parts: string[] = [];
@@ -242,11 +263,15 @@ export async function runClaude(
242
263
  const output = parts.join("\n\n") || stderrOutput || "(no output)";
243
264
 
244
265
  if (killed) {
245
- api.logger.warn(`Claude timed out after ${timeout}ms`);
266
+ const errorType = killedByWatchdog ? "inactivity_timeout" : "timeout";
267
+ const reason = killedByWatchdog
268
+ ? `Claude killed by inactivity watchdog (no I/O for ${Math.round(wdConfig.inactivityMs / 1000)}s)`
269
+ : `Claude timed out after ${Math.round(timeout / 1000)}s`;
270
+ api.logger.warn(reason);
246
271
  resolve({
247
272
  success: false,
248
- output: `Claude timed out after ${Math.round(timeout / 1000)}s. Partial output:\n${output}`,
249
- error: "timeout",
273
+ output: `${reason}. Partial output:\n${output}`,
274
+ error: errorType,
250
275
  });
251
276
  return;
252
277
  }
@@ -267,6 +292,7 @@ export async function runClaude(
267
292
 
268
293
  child.on("error", (err) => {
269
294
  clearTimeout(timer);
295
+ watchdog.stop();
270
296
  rl.close();
271
297
  api.logger.error(`Claude spawn error: ${err}`);
272
298
  resolve({
@@ -1,9 +1,10 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import type { LinearAgentApi } from "./linear-api.js";
3
- import { resolveLinearToken, LinearAgentApi as LinearAgentApiClass } from "./linear-api.js";
4
- import { getCurrentSession, getActiveSessionByIdentifier } from "./active-session.js";
2
+ import type { LinearAgentApi } from "../api/linear-api.js";
3
+ import { resolveLinearToken, LinearAgentApi as LinearAgentApiClass } from "../api/linear-api.js";
4
+ import { getCurrentSession, getActiveSessionByIdentifier } from "../pipeline/active-session.js";
5
5
 
6
- export const DEFAULT_TIMEOUT_MS = 10 * 60_000; // 10 minutes
6
+ export const DEFAULT_TIMEOUT_MS = 10 * 60_000; // 10 minutes (legacy — prefer watchdog config)
7
+ export { DEFAULT_INACTIVITY_SEC, DEFAULT_MAX_TOTAL_SEC, DEFAULT_TOOL_TIMEOUT_SEC } from "../agent/watchdog.js";
7
8
  export const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
8
9
 
9
10
  export interface CliToolParams {
@@ -3,7 +3,7 @@ import { join, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
5
5
  import { jsonResult } from "openclaw/plugin-sdk";
6
- import { getCurrentSession } from "./active-session.js";
6
+ import { getCurrentSession } from "../pipeline/active-session.js";
7
7
  import { runCodex } from "./codex-tool.js";
8
8
  import { runClaude } from "./claude-tool.js";
9
9
  import { runGemini } from "./gemini-tool.js";
@@ -43,7 +43,7 @@ export interface CodingToolsConfig {
43
43
  export function loadCodingConfig(): CodingToolsConfig {
44
44
  try {
45
45
  // Resolve relative to the plugin root (one level up from src/)
46
- const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
46
+ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
47
47
  const raw = readFileSync(join(pluginRoot, "coding-tools.json"), "utf8");
48
48
  return JSON.parse(raw) as CodingToolsConfig;
49
49
  } catch {
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
4
- import type { ActivityContent } from "./linear-api.js";
4
+ import type { ActivityContent } from "../api/linear-api.js";
5
5
  import {
6
6
  buildLinearApi,
7
7
  resolveSession,
@@ -11,6 +11,7 @@ import {
11
11
  type CliToolParams,
12
12
  type CliResult,
13
13
  } from "./cli-shared.js";
14
+ import { InactivityWatchdog, resolveWatchdogConfig } from "../agent/watchdog.js";
14
15
 
15
16
  const CODEX_BIN = "/home/claw/.npm-global/bin/codex";
16
17
 
@@ -103,7 +104,9 @@ export async function runCodex(
103
104
 
104
105
  api.logger.info(`codex_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
105
106
 
106
- const timeout = timeoutMs ?? (pluginConfig?.codexTimeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
107
+ const agentId = (params as any).agentId ?? (pluginConfig?.defaultAgentId as string) ?? "default";
108
+ const wdConfig = resolveWatchdogConfig(agentId, pluginConfig ?? undefined);
109
+ const timeout = timeoutMs ?? (pluginConfig?.codexTimeoutMs as number) ?? wdConfig.toolTimeoutMs;
107
110
  const workingDir = params.workingDir ?? (pluginConfig?.codexBaseRepo as string) ?? DEFAULT_BASE_REPO;
108
111
 
109
112
  // Build Linear API for activity streaming
@@ -134,12 +137,27 @@ export async function runCodex(
134
137
  });
135
138
 
136
139
  let killed = false;
140
+ let killedByWatchdog = false;
137
141
  const timer = setTimeout(() => {
138
142
  killed = true;
139
143
  child.kill("SIGTERM");
140
144
  setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5_000);
141
145
  }, timeout);
142
146
 
147
+ const watchdog = new InactivityWatchdog({
148
+ inactivityMs: wdConfig.inactivityMs,
149
+ label: `codex:${agentSessionId ?? "unknown"}`,
150
+ logger: api.logger,
151
+ onKill: () => {
152
+ killedByWatchdog = true;
153
+ killed = true;
154
+ clearTimeout(timer);
155
+ child.kill("SIGTERM");
156
+ setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5_000);
157
+ },
158
+ });
159
+ watchdog.start();
160
+
143
161
  const collectedMessages: string[] = [];
144
162
  const collectedCommands: string[] = [];
145
163
  let stderrOutput = "";
@@ -147,6 +165,7 @@ export async function runCodex(
147
165
  const rl = createInterface({ input: child.stdout! });
148
166
  rl.on("line", (line) => {
149
167
  if (!line.trim()) return;
168
+ watchdog.tick();
150
169
 
151
170
  let event: any;
152
171
  try {
@@ -189,11 +208,13 @@ export async function runCodex(
189
208
  });
190
209
 
191
210
  child.stderr?.on("data", (chunk) => {
211
+ watchdog.tick();
192
212
  stderrOutput += chunk.toString();
193
213
  });
194
214
 
195
215
  child.on("close", (code) => {
196
216
  clearTimeout(timer);
217
+ watchdog.stop();
197
218
  rl.close();
198
219
 
199
220
  const parts: string[] = [];
@@ -202,11 +223,15 @@ export async function runCodex(
202
223
  const output = parts.join("\n\n") || stderrOutput || "(no output)";
203
224
 
204
225
  if (killed) {
205
- api.logger.warn(`Codex timed out after ${timeout}ms`);
226
+ const errorType = killedByWatchdog ? "inactivity_timeout" : "timeout";
227
+ const reason = killedByWatchdog
228
+ ? `Codex killed by inactivity watchdog (no I/O for ${Math.round(wdConfig.inactivityMs / 1000)}s)`
229
+ : `Codex timed out after ${Math.round(timeout / 1000)}s`;
230
+ api.logger.warn(reason);
206
231
  resolve({
207
232
  success: false,
208
- output: `Codex timed out after ${Math.round(timeout / 1000)}s. Partial output:\n${output}`,
209
- error: "timeout",
233
+ output: `${reason}. Partial output:\n${output}`,
234
+ error: errorType,
210
235
  });
211
236
  return;
212
237
  }
@@ -227,6 +252,7 @@ export async function runCodex(
227
252
 
228
253
  child.on("error", (err) => {
229
254
  clearTimeout(timer);
255
+ watchdog.stop();
230
256
  rl.close();
231
257
  api.logger.error(`Codex spawn error: ${err}`);
232
258
  resolve({