@calltelemetry/openclaw-linear 0.9.15 → 0.9.17
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/README.md +104 -48
- package/index.ts +7 -0
- package/openclaw.plugin.json +44 -2
- package/package.json +1 -1
- package/prompts.yaml +3 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +37 -50
- package/src/agent/agent.test.ts +1 -1
- package/src/agent/agent.ts +39 -6
- package/src/api/linear-api.test.ts +188 -1
- package/src/api/linear-api.ts +114 -5
- package/src/infra/multi-repo.test.ts +127 -1
- package/src/infra/multi-repo.ts +74 -6
- package/src/infra/tmux-runner.ts +225 -0
- package/src/infra/tmux.ts +44 -0
- package/src/pipeline/active-session.ts +19 -1
- package/src/pipeline/artifacts.ts +42 -0
- package/src/pipeline/dispatch-state.ts +3 -0
- package/src/pipeline/guidance.test.ts +53 -0
- package/src/pipeline/guidance.ts +38 -0
- package/src/pipeline/pipeline.ts +184 -17
- package/src/pipeline/webhook.test.ts +1 -1
- package/src/pipeline/webhook.ts +271 -30
- package/src/tools/claude-tool.ts +68 -10
- package/src/tools/cli-shared.ts +50 -2
- package/src/tools/code-tool.ts +230 -150
- package/src/tools/codex-tool.ts +61 -9
- package/src/tools/gemini-tool.ts +61 -10
- package/src/tools/planner-tools.ts +1 -0
- package/src/tools/steering-tools.ts +141 -0
- package/src/tools/tools.test.ts +47 -15
- package/src/tools/tools.ts +17 -4
- package/src/__test__/smoke-linear-api.test.ts +0 -847
package/src/pipeline/webhook.ts
CHANGED
|
@@ -18,6 +18,9 @@ import { emitDiagnostic } from "../infra/observability.js";
|
|
|
18
18
|
import { classifyIntent, type Intent } from "./intent-classify.js";
|
|
19
19
|
import { extractGuidance, formatGuidanceAppendix, cacheGuidanceForTeam, getCachedGuidanceForTeam, isGuidanceEnabled, _resetGuidanceCacheForTesting } from "./guidance.js";
|
|
20
20
|
import { loadAgentProfiles, buildMentionPattern, resolveAgentFromAlias, validateProfiles, _resetProfilesCacheForTesting, type AgentProfile } from "../infra/shared-profiles.js";
|
|
21
|
+
import { getActiveTmuxSession } from "../infra/tmux-runner.js";
|
|
22
|
+
import { capturePane } from "../infra/tmux.js";
|
|
23
|
+
import { loadCodingConfig, resolveToolName } from "../tools/code-tool.js";
|
|
21
24
|
|
|
22
25
|
// ── Prompt input sanitization ─────────────────────────────────────
|
|
23
26
|
|
|
@@ -96,6 +99,7 @@ function wasRecentlyProcessed(key: string): boolean {
|
|
|
96
99
|
export function _resetForTesting(): void {
|
|
97
100
|
activeRuns.clear();
|
|
98
101
|
recentlyProcessed.clear();
|
|
102
|
+
recentlyEmittedActivities.clear();
|
|
99
103
|
_resetProfilesCacheForTesting();
|
|
100
104
|
linearApiCache = null;
|
|
101
105
|
lastSweep = Date.now();
|
|
@@ -105,6 +109,36 @@ export function _resetForTesting(): void {
|
|
|
105
109
|
_resetAffinityForTesting();
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
// ── Feedback loop prevention for steering ─────────────────────────────
|
|
113
|
+
// Track recently emitted activity body hashes to prevent our own emissions
|
|
114
|
+
// from triggering the steering handler.
|
|
115
|
+
const recentlyEmittedActivities = new Map<string, number>();
|
|
116
|
+
const EMITTED_TTL_MS = 30_000;
|
|
117
|
+
|
|
118
|
+
function hashActivityBody(body: string): string {
|
|
119
|
+
// Simple fast hash — not crypto, just dedup
|
|
120
|
+
let hash = 0;
|
|
121
|
+
for (let i = 0; i < Math.min(body.length, 200); i++) {
|
|
122
|
+
hash = ((hash << 5) - hash + body.charCodeAt(i)) | 0;
|
|
123
|
+
}
|
|
124
|
+
return String(hash);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function trackEmittedActivity(body: string): void {
|
|
128
|
+
const hash = hashActivityBody(body);
|
|
129
|
+
recentlyEmittedActivities.set(hash, Date.now());
|
|
130
|
+
// Prune entries older than TTL
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
for (const [k, ts] of recentlyEmittedActivities) {
|
|
133
|
+
if (now - ts > EMITTED_TTL_MS) recentlyEmittedActivities.delete(k);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function wasRecentlyEmitted(body: string): boolean {
|
|
138
|
+
const hash = hashActivityBody(body);
|
|
139
|
+
return recentlyEmittedActivities.has(hash);
|
|
140
|
+
}
|
|
141
|
+
|
|
108
142
|
/** @internal — test-only; add an issue ID to the activeRuns set. */
|
|
109
143
|
export function _addActiveRunForTesting(issueId: string): void {
|
|
110
144
|
activeRuns.add(issueId);
|
|
@@ -428,9 +462,7 @@ export async function handleLinearWebhook(
|
|
|
428
462
|
}
|
|
429
463
|
}
|
|
430
464
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
// Fetch full issue details
|
|
465
|
+
// Fetch full issue details (needed for team routing + description)
|
|
434
466
|
let enrichedIssue: any = issue;
|
|
435
467
|
try {
|
|
436
468
|
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
@@ -438,6 +470,19 @@ export async function handleLinearWebhook(
|
|
|
438
470
|
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
439
471
|
}
|
|
440
472
|
|
|
473
|
+
// Team-based agent routing: if no mention or affinity override, try team mapping
|
|
474
|
+
const teamKey = enrichedIssue?.team?.key as string | undefined;
|
|
475
|
+
if (!mentionOverride && agentId === resolveAgentId(api) && teamKey) {
|
|
476
|
+
const teamMappings = (pluginConfig as Record<string, unknown>)?.teamMappings as Record<string, any> | undefined;
|
|
477
|
+
const teamDefault = teamMappings?.[teamKey]?.defaultAgent;
|
|
478
|
+
if (typeof teamDefault === "string") {
|
|
479
|
+
api.logger.info(`AgentSession routed to ${teamDefault} via team mapping (${teamKey})`);
|
|
480
|
+
agentId = teamDefault;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} agent=${agentId} team=${teamKey ?? "?"} (comments: ${previousComments.length}, guidance: ${guidanceCtx.guidance ? "yes" : "no"})`);
|
|
485
|
+
|
|
441
486
|
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
442
487
|
|
|
443
488
|
// Cache guidance for this team (enables Comment webhook paths)
|
|
@@ -456,12 +501,13 @@ export async function handleLinearWebhook(
|
|
|
456
501
|
const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
|
|
457
502
|
const stateType = enrichedIssue?.state?.type ?? "";
|
|
458
503
|
const isTriaged = stateType === "started" || stateType === "completed" || stateType === "canceled";
|
|
504
|
+
const cliTool = resolveToolName(loadCodingConfig(), agentId);
|
|
459
505
|
|
|
460
506
|
const toolAccessLines = isTriaged
|
|
461
507
|
? [
|
|
462
508
|
`**Tool access:**`,
|
|
463
509
|
`- \`linear_issues\` tool: Full access. Use action="read" with issueId="${issueRef}" to get details, action="create" to create issues (with parentIssueId to create sub-issues for granular work breakdown), action="update" with status/priority/labels/estimate to modify issues, action="comment" to post comments, action="list_states" to see available workflow states.`,
|
|
464
|
-
`-
|
|
510
|
+
`- \`${cliTool}\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
|
|
465
511
|
`- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
|
|
466
512
|
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
467
513
|
``,
|
|
@@ -470,14 +516,14 @@ export async function handleLinearWebhook(
|
|
|
470
516
|
: [
|
|
471
517
|
`**Tool access:**`,
|
|
472
518
|
`- \`linear_issues\` tool: READ ONLY. Use action="read" with issueId="${issueRef}" to get details, action="list_states"/"list_labels" for metadata. Do NOT use action="update", action="create", or action="comment".`,
|
|
473
|
-
`-
|
|
519
|
+
`- \`${cliTool}\`: **Planning mode only.** Workers may explore code and write plan files (PLAN.md, design docs). Workers MUST NOT create, modify, or delete source code, run deployments, or make system changes. Use for codebase exploration and planning only.`,
|
|
474
520
|
`- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
|
|
475
521
|
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
476
522
|
];
|
|
477
523
|
|
|
478
524
|
const roleLines = isTriaged
|
|
479
|
-
? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via
|
|
480
|
-
: [`**Your role:** You are the dispatcher. For any coding or implementation work, use \`
|
|
525
|
+
? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`${cliTool}\`. Do NOT post comments yourself — the handler posts your text output.`]
|
|
526
|
+
: [`**Your role:** You are the dispatcher. For any coding or implementation work, use \`${cliTool}\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post comments via linear_issues — the audit system handles lifecycle transitions.`];
|
|
481
527
|
|
|
482
528
|
if (guidanceAppendix) {
|
|
483
529
|
api.logger.info(`Guidance injected (${guidanceCtx.source}): ${guidanceCtx.guidance?.slice(0, 120)}...`);
|
|
@@ -530,10 +576,10 @@ export async function handleLinearWebhook(
|
|
|
530
576
|
``,
|
|
531
577
|
`## Scope Rules`,
|
|
532
578
|
`1. **Read the issue first.** The issue title + description define your scope. Everything you do must serve the issue as written.`,
|
|
533
|
-
`2.
|
|
534
|
-
`3. **Comments explore, issue body builds.** User comments may explore scope or ask questions but NEVER trigger \`
|
|
535
|
-
`4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`
|
|
536
|
-
`5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements → no
|
|
579
|
+
`2. **\`${cliTool}\` is ONLY for issue-body work.** Only dispatch \`${cliTool}\` when the issue description contains implementation requirements. A greeting, question, or conversational issue gets a conversational response — NOT ${cliTool}.`,
|
|
580
|
+
`3. **Comments explore, issue body builds.** User comments may explore scope or ask questions but NEVER trigger \`${cliTool}\` alone. If a comment requests new implementation, update the issue description first, then build from the issue text.`,
|
|
581
|
+
`4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`${cliTool}\` after the plan is clear and grounded in the issue body.`,
|
|
582
|
+
`5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements → no ${cliTool}.`,
|
|
537
583
|
``,
|
|
538
584
|
`Respond within the scope defined above. Be concise and action-oriented.`,
|
|
539
585
|
].filter(Boolean).join("\n");
|
|
@@ -544,7 +590,7 @@ export async function handleLinearWebhook(
|
|
|
544
590
|
const profiles = loadAgentProfiles();
|
|
545
591
|
const label = profiles[agentId]?.label ?? agentId;
|
|
546
592
|
|
|
547
|
-
// Register active session for tool resolution (
|
|
593
|
+
// Register active session for tool resolution (cli_codex, etc.)
|
|
548
594
|
// Also eagerly records affinity so follow-ups route to the same agent.
|
|
549
595
|
setActiveSession({
|
|
550
596
|
agentSessionId: session.id,
|
|
@@ -631,10 +677,45 @@ export async function handleLinearWebhook(
|
|
|
631
677
|
return true;
|
|
632
678
|
}
|
|
633
679
|
|
|
634
|
-
//
|
|
635
|
-
// our own
|
|
680
|
+
// ── Steering gate: three-way routing during active runs ──
|
|
681
|
+
// 1. Filter our own emitted activities (feedback loop prevention)
|
|
682
|
+
const activityType = activity?.content?.type;
|
|
683
|
+
const activityBody = activity?.content?.body ?? activity?.body ?? "";
|
|
684
|
+
const isOurFeedback =
|
|
685
|
+
activityType === "thought" ||
|
|
686
|
+
activityType === "action" ||
|
|
687
|
+
activityType === "response" ||
|
|
688
|
+
activityType === "error" ||
|
|
689
|
+
activityType === "elicitation" ||
|
|
690
|
+
(typeof activityBody === "string" && wasRecentlyEmitted(activityBody));
|
|
691
|
+
if (isOurFeedback) {
|
|
692
|
+
api.logger.info(`AgentSession prompted: ${session.id} — feedback from own activity (${activityType ?? "hash-match"}), ignoring`);
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// 2. If active dispatch with tmux session → route to steering orchestrator
|
|
697
|
+
const tmuxSession = getActiveTmuxSession(issue.id);
|
|
698
|
+
if (tmuxSession) {
|
|
699
|
+
const userText = activityBody;
|
|
700
|
+
if (!userText || typeof userText !== "string" || !userText.trim()) {
|
|
701
|
+
api.logger.info(`AgentSession prompted: ${session.id} — tmux active but empty user message, ignoring`);
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
api.logger.info(`AgentSession prompted: ${session.id} issue=${issue?.identifier ?? issue?.id} — routing to steering orchestrator (${tmuxSession.backend})`);
|
|
706
|
+
void handleSteeringInput(api, {
|
|
707
|
+
session,
|
|
708
|
+
issue,
|
|
709
|
+
userMessage: userText,
|
|
710
|
+
tmuxSession,
|
|
711
|
+
pluginConfig: pluginConfig as Record<string, unknown> | undefined,
|
|
712
|
+
});
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// 3. No tmux session but active run → existing behavior (ignore feedback)
|
|
636
717
|
if (activeRuns.has(issue.id)) {
|
|
637
|
-
api.logger.info(`AgentSession prompted: ${session.id} issue=${issue?.identifier ?? issue?.id} — agent active, ignoring (feedback)`);
|
|
718
|
+
api.logger.info(`AgentSession prompted: ${session.id} issue=${issue?.identifier ?? issue?.id} — agent active, no tmux, ignoring (feedback)`);
|
|
638
719
|
return true;
|
|
639
720
|
}
|
|
640
721
|
|
|
@@ -735,12 +816,13 @@ export async function handleLinearWebhook(
|
|
|
735
816
|
const followUpIssueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
|
|
736
817
|
const followUpStateType = enrichedIssue?.state?.type ?? "";
|
|
737
818
|
const followUpIsTriaged = followUpStateType === "started" || followUpStateType === "completed" || followUpStateType === "canceled";
|
|
819
|
+
const followUpCliTool = resolveToolName(loadCodingConfig(), agentId);
|
|
738
820
|
|
|
739
821
|
const followUpToolAccessLines = followUpIsTriaged
|
|
740
822
|
? [
|
|
741
823
|
`**Tool access:**`,
|
|
742
824
|
`- \`linear_issues\` tool: Full access. Use action="read" with issueId="${followUpIssueRef}" to get details, action="create" to create issues (with parentIssueId to create sub-issues for granular work breakdown), action="update" with status/priority/labels/estimate to modify issues, action="comment" to post comments, action="list_states" to see available workflow states.`,
|
|
743
|
-
`-
|
|
825
|
+
`- \`${followUpCliTool}\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
|
|
744
826
|
`- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
|
|
745
827
|
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
746
828
|
``,
|
|
@@ -749,14 +831,14 @@ export async function handleLinearWebhook(
|
|
|
749
831
|
: [
|
|
750
832
|
`**Tool access:**`,
|
|
751
833
|
`- \`linear_issues\` tool: READ ONLY. Use action="read" with issueId="${followUpIssueRef}" to get details, action="list_states"/"list_labels" for metadata. Do NOT use action="update", action="create", or action="comment".`,
|
|
752
|
-
`-
|
|
834
|
+
`- \`${followUpCliTool}\`: **Planning mode only.** Workers may explore code and write plan files (PLAN.md, design docs). Workers MUST NOT create, modify, or delete source code, run deployments, or make system changes. Use for codebase exploration and planning only.`,
|
|
753
835
|
`- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
|
|
754
836
|
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
755
837
|
];
|
|
756
838
|
|
|
757
839
|
const followUpRoleLines = followUpIsTriaged
|
|
758
|
-
? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via
|
|
759
|
-
: [`**Your role:** Dispatcher. For work requests, use
|
|
840
|
+
? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`${followUpCliTool}\`. Do NOT post comments yourself — the handler posts your text output.`]
|
|
841
|
+
: [`**Your role:** Dispatcher. For work requests, use \`${followUpCliTool}\`. You do NOT update issue status — the audit system handles lifecycle.`];
|
|
760
842
|
|
|
761
843
|
if (followUpGuidanceAppendix) {
|
|
762
844
|
api.logger.info(`Follow-up guidance injected: ${(guidanceCtxPrompted.guidance ?? "cached").slice(0, 120)}...`);
|
|
@@ -811,7 +893,7 @@ export async function handleLinearWebhook(
|
|
|
811
893
|
``,
|
|
812
894
|
`## Scope Rules`,
|
|
813
895
|
`1. **The issue body is your scope.** Re-read the description above before acting.`,
|
|
814
|
-
`2. **Comments explore, issue body builds.** The follow-up may refine understanding or ask questions — NEVER dispatch \`
|
|
896
|
+
`2. **Comments explore, issue body builds.** The follow-up may refine understanding or ask questions — NEVER dispatch \`${followUpCliTool}\` from a comment alone. If the user requests implementation, suggest updating the issue description first.`,
|
|
815
897
|
`3. **Match response to request.** Answer questions with answers. Do NOT escalate conversational messages into builds.`,
|
|
816
898
|
``,
|
|
817
899
|
`Respond to the follow-up within the scope defined above. Be concise and action-oriented.`,
|
|
@@ -1579,12 +1661,13 @@ async function dispatchCommentToAgent(
|
|
|
1579
1661
|
const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
|
|
1580
1662
|
const stateType = enrichedIssue?.state?.type ?? "";
|
|
1581
1663
|
const isTriaged = stateType === "started" || stateType === "completed" || stateType === "canceled";
|
|
1664
|
+
const cliTool = resolveToolName(loadCodingConfig(), agentId);
|
|
1582
1665
|
|
|
1583
1666
|
const toolAccessLines = isTriaged
|
|
1584
1667
|
? [
|
|
1585
1668
|
`**Tool access:**`,
|
|
1586
1669
|
`- \`linear_issues\` tool: Full access. Use action="read" with issueId="${issueRef}" to get details, action="create" to create issues (with parentIssueId to create sub-issues for granular work breakdown), action="update" with status/priority/labels/estimate to modify issues, action="comment" to post comments, action="list_states" to see available workflow states.`,
|
|
1587
|
-
`-
|
|
1670
|
+
`- \`${cliTool}\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
|
|
1588
1671
|
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
1589
1672
|
``,
|
|
1590
1673
|
`**Sub-issue guidance:** When a task is too large or has multiple distinct parts, break it into sub-issues using action="create" with parentIssueId="${issueRef}". Each sub-issue should be an atomic, independently testable unit of work with its own acceptance criteria. This enables parallel dispatch and clearer progress tracking.`,
|
|
@@ -1592,13 +1675,13 @@ async function dispatchCommentToAgent(
|
|
|
1592
1675
|
: [
|
|
1593
1676
|
`**Tool access:**`,
|
|
1594
1677
|
`- \`linear_issues\` tool: READ ONLY. Use action="read" with issueId="${issueRef}" to get details, action="list_states"/"list_labels" for metadata. Do NOT use action="update", action="create", or action="comment".`,
|
|
1595
|
-
`-
|
|
1678
|
+
`- \`${cliTool}\`: **Planning mode only.** Workers may explore code and write plan files (PLAN.md, design docs). Workers MUST NOT create, modify, or delete source code, run deployments, or make system changes. Use for codebase exploration and planning only.`,
|
|
1596
1679
|
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
1597
1680
|
];
|
|
1598
1681
|
|
|
1599
1682
|
const roleLines = isTriaged
|
|
1600
|
-
? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via
|
|
1601
|
-
: [`**Your role:** Dispatcher. For work requests, use
|
|
1683
|
+
? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`${cliTool}\`. Do NOT post comments yourself — the handler posts your text output.`]
|
|
1684
|
+
: [`**Your role:** Dispatcher. For work requests, use \`${cliTool}\`. You do NOT update issue status — the audit system handles lifecycle.`];
|
|
1602
1685
|
|
|
1603
1686
|
const message = [
|
|
1604
1687
|
`You are an orchestrator responding to a Linear comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
|
|
@@ -1620,10 +1703,10 @@ async function dispatchCommentToAgent(
|
|
|
1620
1703
|
``,
|
|
1621
1704
|
`## Scope Rules`,
|
|
1622
1705
|
`1. **Read the issue first.** The issue title + description define your scope. Everything you do must serve the issue as written.`,
|
|
1623
|
-
`2.
|
|
1624
|
-
`3. **Comments explore, issue body builds.** The comment above may explore scope or ask questions but NEVER trigger \`
|
|
1625
|
-
`4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`
|
|
1626
|
-
`5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements in the issue body → no
|
|
1706
|
+
`2. **\`${cliTool}\` is ONLY for issue-body work.** Only dispatch \`${cliTool}\` when the issue description contains implementation requirements. A greeting, question, or conversational issue gets a conversational response — NOT ${cliTool}.`,
|
|
1707
|
+
`3. **Comments explore, issue body builds.** The comment above may explore scope or ask questions but NEVER trigger \`${cliTool}\` from a comment alone. If the comment requests new implementation, suggest updating the issue description or creating a new issue.`,
|
|
1708
|
+
`4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`${cliTool}\` after the plan is clear and grounded in the issue body.`,
|
|
1709
|
+
`5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements in the issue body → no ${cliTool}.`,
|
|
1627
1710
|
``,
|
|
1628
1711
|
`Respond within the scope defined above. Be concise and action-oriented.`,
|
|
1629
1712
|
commentGuidanceAppendix,
|
|
@@ -1973,9 +2056,11 @@ async function handleDispatch(
|
|
|
1973
2056
|
|
|
1974
2057
|
const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
|
|
1975
2058
|
const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
|
|
2059
|
+
const dispatchTeamKey = enrichedIssue?.team?.key as string | undefined;
|
|
1976
2060
|
|
|
1977
|
-
// Resolve repos for this dispatch (issue body markers
|
|
1978
|
-
const repoResolution = resolveRepos(enrichedIssue.description, labels, pluginConfig);
|
|
2061
|
+
// Resolve repos for this dispatch (issue body markers → labels → team mapping → config default)
|
|
2062
|
+
const repoResolution = resolveRepos(enrichedIssue.description, labels, pluginConfig, dispatchTeamKey);
|
|
2063
|
+
api.logger.info(`@dispatch: ${identifier} team=${dispatchTeamKey ?? "none"} repos=${repoResolution.repos.map(r => r.name).join(",")} source=${repoResolution.source}`);
|
|
1979
2064
|
|
|
1980
2065
|
// 4. Assess complexity tier
|
|
1981
2066
|
const assessment = await assessTier(api, {
|
|
@@ -2092,6 +2177,20 @@ async function handleDispatch(
|
|
|
2092
2177
|
worktrees,
|
|
2093
2178
|
}, statePath);
|
|
2094
2179
|
|
|
2180
|
+
// 7b. Linear state transition: set issue to "In Progress" (best-effort)
|
|
2181
|
+
if (enrichedIssue?.team?.id) {
|
|
2182
|
+
try {
|
|
2183
|
+
const teamStates = await linearApi.getTeamStates(enrichedIssue.team.id);
|
|
2184
|
+
const inProgress = teamStates.find((s: any) => s.type === "started");
|
|
2185
|
+
if (inProgress) {
|
|
2186
|
+
await linearApi.updateIssue(issue.id, { stateId: inProgress.id });
|
|
2187
|
+
api.logger.info(`@dispatch: ${identifier} → ${inProgress.name} (In Progress)`);
|
|
2188
|
+
}
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
api.logger.warn(`@dispatch: ${identifier} — failed to set In Progress state: ${err}`);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2095
2194
|
// 8. Register active session for tool resolution
|
|
2096
2195
|
setActiveSession({
|
|
2097
2196
|
agentSessionId: agentSessionId ?? "",
|
|
@@ -2194,3 +2293,145 @@ async function handleDispatch(
|
|
|
2194
2293
|
clearActiveSession(issue.id);
|
|
2195
2294
|
});
|
|
2196
2295
|
}
|
|
2296
|
+
|
|
2297
|
+
// ── Steering handler ──────────────────────────────────────────────
|
|
2298
|
+
//
|
|
2299
|
+
// Handle user input during an active tmux-wrapped dispatch.
|
|
2300
|
+
// Routes to a short orchestrator agent session that can steer, capture, or abort.
|
|
2301
|
+
|
|
2302
|
+
async function handleSteeringInput(
|
|
2303
|
+
api: OpenClawPluginApi,
|
|
2304
|
+
ctx: {
|
|
2305
|
+
session: any;
|
|
2306
|
+
issue: any;
|
|
2307
|
+
userMessage: string;
|
|
2308
|
+
tmuxSession: { sessionName: string; backend: string; issueIdentifier: string; issueId: string; steeringMode: string };
|
|
2309
|
+
pluginConfig?: Record<string, unknown>;
|
|
2310
|
+
},
|
|
2311
|
+
): Promise<void> {
|
|
2312
|
+
const { session, issue, userMessage, tmuxSession, pluginConfig } = ctx;
|
|
2313
|
+
|
|
2314
|
+
const linearApi = createLinearApi(api);
|
|
2315
|
+
if (!linearApi) {
|
|
2316
|
+
api.logger.error("handleSteeringInput: no Linear API");
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// 1. Capture recent coding agent output for context
|
|
2321
|
+
let agentOutput = "";
|
|
2322
|
+
try {
|
|
2323
|
+
agentOutput = capturePane(tmuxSession.sessionName, 50);
|
|
2324
|
+
} catch (err) {
|
|
2325
|
+
api.logger.warn(`handleSteeringInput: capturePane failed: ${err}`);
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// 2. Read dispatch state for context
|
|
2329
|
+
let dispatchCtx = "";
|
|
2330
|
+
try {
|
|
2331
|
+
const state = await readDispatchState(pluginConfig?.dispatchStatePath as string | undefined);
|
|
2332
|
+
const dispatch = getActiveDispatch(state, tmuxSession.issueIdentifier);
|
|
2333
|
+
if (dispatch) {
|
|
2334
|
+
dispatchCtx = [
|
|
2335
|
+
`Worktree: ${dispatch.worktreePath}`,
|
|
2336
|
+
`Attempt: ${dispatch.attempt} | Status: ${dispatch.status}`,
|
|
2337
|
+
`Tier: ${dispatch.tier}`,
|
|
2338
|
+
].join("\n");
|
|
2339
|
+
}
|
|
2340
|
+
} catch { /* proceed without dispatch context */ }
|
|
2341
|
+
|
|
2342
|
+
// 3. Build steering prompt
|
|
2343
|
+
const prompt = [
|
|
2344
|
+
`You are a steering orchestrator. A ${tmuxSession.backend} coding agent is currently ` +
|
|
2345
|
+
`working on issue ${tmuxSession.issueIdentifier}. The user just sent a message in the Linear session.`,
|
|
2346
|
+
``,
|
|
2347
|
+
`## Issue Context`,
|
|
2348
|
+
`**${tmuxSession.issueIdentifier}**: ${issue?.title ?? "(untitled)"}`,
|
|
2349
|
+
dispatchCtx || `Backend: ${tmuxSession.backend}`,
|
|
2350
|
+
`Steering mode: ${tmuxSession.steeringMode}`,
|
|
2351
|
+
``,
|
|
2352
|
+
`## Recent Agent Output (last 50 lines)`,
|
|
2353
|
+
"```",
|
|
2354
|
+
agentOutput || "(no output captured)",
|
|
2355
|
+
"```",
|
|
2356
|
+
``,
|
|
2357
|
+
`## User's Message`,
|
|
2358
|
+
`> ${sanitizePromptInput(userMessage, 2000)}`,
|
|
2359
|
+
``,
|
|
2360
|
+
`## Your Decision`,
|
|
2361
|
+
`Analyze the user's message in the context of what the coding agent is doing.`,
|
|
2362
|
+
``,
|
|
2363
|
+
`**Use \`steer_agent\`** (issueId="${issue.id}") if the user is:`,
|
|
2364
|
+
`- Providing information the agent needs (docs location, tool name, API details)`,
|
|
2365
|
+
`- Answering a question the agent asked`,
|
|
2366
|
+
`- Redirecting the agent's approach ("focus on X first", "use library Y")`,
|
|
2367
|
+
`→ Craft a PRECISE, actionable message. Don't forward raw user text.`,
|
|
2368
|
+
` Translate vague input into clear instructions with file paths, code refs, etc.`,
|
|
2369
|
+
tmuxSession.steeringMode === "one-shot"
|
|
2370
|
+
? `⚠️ WARNING: ${tmuxSession.backend} is in ONE-SHOT mode — steer_agent will fail. You can only abort or respond directly.`
|
|
2371
|
+
: "",
|
|
2372
|
+
``,
|
|
2373
|
+
`**Use \`capture_agent_output\`** (issueId="${issue.id}") if you need more context before deciding.`,
|
|
2374
|
+
``,
|
|
2375
|
+
`**Use \`abort_agent\`** (issueId="${issue.id}") if the user wants to stop/cancel the run.`,
|
|
2376
|
+
``,
|
|
2377
|
+
`**Respond directly (just output text)** if the user is:`,
|
|
2378
|
+
`- Asking a status question ("what's it doing?", "how far along?")`,
|
|
2379
|
+
`- Making a request for you, not the coding agent`,
|
|
2380
|
+
``,
|
|
2381
|
+
`Be fast and decisive. This is a mid-task steering call, not a conversation.`,
|
|
2382
|
+
].filter(Boolean).join("\n");
|
|
2383
|
+
|
|
2384
|
+
// 4. Emit acknowledgment
|
|
2385
|
+
const ackBody = `Processing your input while ${tmuxSession.backend} agent is working...`;
|
|
2386
|
+
trackEmittedActivity(ackBody);
|
|
2387
|
+
await linearApi.emitActivity(session.id, {
|
|
2388
|
+
type: "thought",
|
|
2389
|
+
body: ackBody,
|
|
2390
|
+
}).catch(() => {});
|
|
2391
|
+
|
|
2392
|
+
// 5. Run SHORT orchestrator session (60s timeout)
|
|
2393
|
+
try {
|
|
2394
|
+
const { runAgent } = await import("../agent/agent.js");
|
|
2395
|
+
const result = await runAgent({
|
|
2396
|
+
api,
|
|
2397
|
+
agentId: resolveAgentId(api),
|
|
2398
|
+
sessionId: `linear-steer-${session.id}-${Date.now()}`,
|
|
2399
|
+
message: prompt,
|
|
2400
|
+
timeoutMs: 60_000,
|
|
2401
|
+
streaming: { linearApi, agentSessionId: session.id },
|
|
2402
|
+
toolsDeny: [
|
|
2403
|
+
"cli_codex",
|
|
2404
|
+
"cli_claude",
|
|
2405
|
+
"cli_gemini",
|
|
2406
|
+
"dispatch_history",
|
|
2407
|
+
"plan_audit",
|
|
2408
|
+
"plan_create_issue",
|
|
2409
|
+
"plan_link_issues",
|
|
2410
|
+
"plan_update_issue",
|
|
2411
|
+
"write",
|
|
2412
|
+
"edit",
|
|
2413
|
+
"apply_patch",
|
|
2414
|
+
"spawn_agent",
|
|
2415
|
+
"ask_agent",
|
|
2416
|
+
],
|
|
2417
|
+
});
|
|
2418
|
+
|
|
2419
|
+
// 6. Post response if orchestrator responded directly (not via tool)
|
|
2420
|
+
if (result.success && result.output.trim()) {
|
|
2421
|
+
const responseBody = result.output;
|
|
2422
|
+
trackEmittedActivity(responseBody);
|
|
2423
|
+
await linearApi.emitActivity(session.id, {
|
|
2424
|
+
type: "response",
|
|
2425
|
+
body: responseBody,
|
|
2426
|
+
}).catch(() => {});
|
|
2427
|
+
}
|
|
2428
|
+
} catch (err) {
|
|
2429
|
+
api.logger.error(`handleSteeringInput error: ${err}`);
|
|
2430
|
+
const errBody = `Steering failed: ${String(err).slice(0, 300)}`;
|
|
2431
|
+
trackEmittedActivity(errBody);
|
|
2432
|
+
await linearApi.emitActivity(session.id, {
|
|
2433
|
+
type: "error",
|
|
2434
|
+
body: errBody,
|
|
2435
|
+
}).catch(() => {});
|
|
2436
|
+
}
|
|
2437
|
+
}
|
package/src/tools/claude-tool.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
5
|
import type { ActivityContent } from "../api/linear-api.js";
|
|
5
6
|
import {
|
|
6
7
|
buildLinearApi,
|
|
7
8
|
resolveSession,
|
|
8
9
|
extractPrompt,
|
|
9
|
-
DEFAULT_TIMEOUT_MS,
|
|
10
10
|
DEFAULT_BASE_REPO,
|
|
11
|
+
formatActivityLogLine,
|
|
12
|
+
createProgressEmitter,
|
|
11
13
|
type CliToolParams,
|
|
12
14
|
type CliResult,
|
|
15
|
+
type OnProgressUpdate,
|
|
13
16
|
} from "./cli-shared.js";
|
|
14
17
|
import { InactivityWatchdog, resolveWatchdogConfig } from "../agent/watchdog.js";
|
|
18
|
+
import { isTmuxAvailable, buildSessionName, shellEscape } from "../infra/tmux.js";
|
|
19
|
+
import { runInTmux } from "../infra/tmux-runner.js";
|
|
15
20
|
|
|
16
21
|
const CLAUDE_BIN = "claude";
|
|
17
22
|
|
|
@@ -47,7 +52,7 @@ function mapClaudeEventToActivity(event: any): ActivityContent | null {
|
|
|
47
52
|
} else if (input.query) {
|
|
48
53
|
paramSummary = String(input.query).slice(0, 200);
|
|
49
54
|
} else {
|
|
50
|
-
paramSummary = JSON.stringify(input).slice(0,
|
|
55
|
+
paramSummary = JSON.stringify(input).slice(0, 500);
|
|
51
56
|
}
|
|
52
57
|
return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
|
|
53
58
|
}
|
|
@@ -63,7 +68,7 @@ function mapClaudeEventToActivity(event: any): ActivityContent | null {
|
|
|
63
68
|
for (const block of content) {
|
|
64
69
|
if (block.type === "tool_result") {
|
|
65
70
|
const output = typeof block.content === "string" ? block.content : "";
|
|
66
|
-
const truncated = output.length >
|
|
71
|
+
const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
|
|
67
72
|
const isError = block.is_error === true;
|
|
68
73
|
return {
|
|
69
74
|
type: "action",
|
|
@@ -100,6 +105,7 @@ export async function runClaude(
|
|
|
100
105
|
api: OpenClawPluginApi,
|
|
101
106
|
params: CliToolParams,
|
|
102
107
|
pluginConfig?: Record<string, unknown>,
|
|
108
|
+
onUpdate?: OnProgressUpdate,
|
|
103
109
|
): Promise<CliResult> {
|
|
104
110
|
api.logger.info(`claude_run params: ${JSON.stringify(params).slice(0, 500)}`);
|
|
105
111
|
|
|
@@ -113,7 +119,7 @@ export async function runClaude(
|
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
const { model, timeoutMs } = params;
|
|
116
|
-
const { agentSessionId, issueIdentifier } = resolveSession(params);
|
|
122
|
+
const { agentSessionId, issueId, issueIdentifier } = resolveSession(params);
|
|
117
123
|
|
|
118
124
|
api.logger.info(`claude_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
|
|
119
125
|
|
|
@@ -143,8 +149,54 @@ export async function runClaude(
|
|
|
143
149
|
}
|
|
144
150
|
args.push("-p", prompt);
|
|
145
151
|
|
|
146
|
-
|
|
152
|
+
const fullCommand = `${CLAUDE_BIN} ${args.join(" ")}`;
|
|
153
|
+
api.logger.info(`Claude exec: ${fullCommand.slice(0, 200)}...`);
|
|
154
|
+
|
|
155
|
+
const progressHeader = `[claude] ${workingDir}\n$ ${fullCommand.slice(0, 500)}\n\nPrompt: ${prompt}`;
|
|
156
|
+
|
|
157
|
+
// --- tmux path: run inside a tmux session with pipe-pane streaming ---
|
|
158
|
+
const tmuxEnabled = pluginConfig?.enableTmux !== false;
|
|
159
|
+
if (tmuxEnabled && isTmuxAvailable()) {
|
|
160
|
+
const sessionName = buildSessionName(issueIdentifier ?? "unknown", "claude", 0);
|
|
161
|
+
const tmuxIssueId = issueId ?? sessionName;
|
|
162
|
+
const modelArgs = (model ?? pluginConfig?.claudeModel)
|
|
163
|
+
? `--model ${shellEscape((model ?? pluginConfig?.claudeModel) as string)}`
|
|
164
|
+
: "";
|
|
165
|
+
// Build env prefix: unset CLAUDECODE (avoids "nested session" error)
|
|
166
|
+
// and inject API key from plugin config if configured
|
|
167
|
+
const envParts: string[] = ["unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT;"];
|
|
168
|
+
const claudeApiKey = pluginConfig?.claudeApiKey as string | undefined;
|
|
169
|
+
if (claudeApiKey) {
|
|
170
|
+
envParts.push(`export ANTHROPIC_API_KEY=${shellEscape(claudeApiKey)};`);
|
|
171
|
+
}
|
|
172
|
+
const cmdStr = [
|
|
173
|
+
...envParts,
|
|
174
|
+
CLAUDE_BIN,
|
|
175
|
+
"--print", "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions",
|
|
176
|
+
modelArgs,
|
|
177
|
+
"-p", shellEscape(prompt),
|
|
178
|
+
].filter(Boolean).join(" ");
|
|
179
|
+
|
|
180
|
+
return runInTmux({
|
|
181
|
+
issueId: tmuxIssueId,
|
|
182
|
+
issueIdentifier: issueIdentifier ?? "unknown",
|
|
183
|
+
sessionName,
|
|
184
|
+
command: cmdStr,
|
|
185
|
+
cwd: workingDir,
|
|
186
|
+
timeoutMs: timeout,
|
|
187
|
+
watchdogMs: wdConfig.inactivityMs,
|
|
188
|
+
logPath: path.join(workingDir, ".claw", `tmux-${sessionName}.jsonl`),
|
|
189
|
+
mapEvent: mapClaudeEventToActivity,
|
|
190
|
+
linearApi: linearApi ?? undefined,
|
|
191
|
+
agentSessionId: agentSessionId ?? undefined,
|
|
192
|
+
steeringMode: "stdin-pipe",
|
|
193
|
+
logger: api.logger,
|
|
194
|
+
onUpdate,
|
|
195
|
+
progressHeader,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
147
198
|
|
|
199
|
+
// --- fallback: direct spawn ---
|
|
148
200
|
return new Promise<CliResult>((resolve) => {
|
|
149
201
|
// Must unset CLAUDECODE to avoid "nested session" error
|
|
150
202
|
const env = { ...process.env };
|
|
@@ -190,6 +242,9 @@ export async function runClaude(
|
|
|
190
242
|
let stderrOutput = "";
|
|
191
243
|
let lastToolName = "";
|
|
192
244
|
|
|
245
|
+
const progress = createProgressEmitter({ header: progressHeader, onUpdate });
|
|
246
|
+
progress.emitHeader();
|
|
247
|
+
|
|
193
248
|
const rl = createInterface({ input: child.stdout! });
|
|
194
249
|
rl.on("line", (line) => {
|
|
195
250
|
if (!line.trim()) return;
|
|
@@ -244,12 +299,15 @@ export async function runClaude(
|
|
|
244
299
|
// (it duplicates the last assistant text message)
|
|
245
300
|
}
|
|
246
301
|
|
|
247
|
-
// Stream activity to Linear
|
|
302
|
+
// Stream activity to Linear + session progress
|
|
248
303
|
const activity = mapClaudeEventToActivity(event);
|
|
249
|
-
if (activity
|
|
250
|
-
linearApi
|
|
251
|
-
|
|
252
|
-
|
|
304
|
+
if (activity) {
|
|
305
|
+
if (linearApi && agentSessionId) {
|
|
306
|
+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
|
|
307
|
+
api.logger.warn(`Failed to emit Claude activity: ${err}`);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
progress.push(formatActivityLogLine(activity));
|
|
253
311
|
}
|
|
254
312
|
});
|
|
255
313
|
|