@calltelemetry/openclaw-linear 0.9.15 → 0.9.16

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.
@@ -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
- api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} agent=${agentId} (comments: ${previousComments.length}, guidance: ${guidanceCtx.guidance ? "yes" : "no"})`);
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
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
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
- `- \`code_run\`: **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.`,
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 \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
480
- : [`**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` 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.`];
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. **\`code_run\` is ONLY for issue-body work.** Only dispatch \`code_run\` when the issue description contains implementation requirements. A greeting, question, or conversational issue gets a conversational response — NOT code_run.`,
534
- `3. **Comments explore, issue body builds.** User comments may explore scope or ask questions but NEVER trigger \`code_run\` alone. If a comment requests new implementation, update the issue description first, then build from the issue text.`,
535
- `4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`code_run\` after the plan is clear and grounded in the issue body.`,
536
- `5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements → no code_run.`,
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 (code_run, etc.)
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
- // If an agent run is already active for this issue, this is feedback from
635
- // our own activity emissions ignore to prevent loops.
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
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
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
- `- \`code_run\`: **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.`,
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 \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
759
- : [`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`];
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 \`code_run\` from a comment alone. If the user requests implementation, suggest updating the issue description first.`,
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
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
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
- `- \`code_run\`: **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.`,
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 \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
1601
- : [`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`];
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. **\`code_run\` is ONLY for issue-body work.** Only dispatch \`code_run\` when the issue description contains implementation requirements. A greeting, question, or conversational issue gets a conversational response — NOT code_run.`,
1624
- `3. **Comments explore, issue body builds.** The comment above may explore scope or ask questions but NEVER trigger \`code_run\` from a comment alone. If the comment requests new implementation, suggest updating the issue description or creating a new issue.`,
1625
- `4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`code_run\` after the plan is clear and grounded in the issue body.`,
1626
- `5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements in the issue body → no code_run.`,
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, labels, or config default)
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
+ }
@@ -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, 200);
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 > 300 ? output.slice(0, 300) + "..." : output;
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
- api.logger.info(`Claude exec: ${CLAUDE_BIN} ${args.join(" ").slice(0, 200)}...`);
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 && linearApi && agentSessionId) {
250
- linearApi.emitActivity(agentSessionId, activity).catch((err) => {
251
- api.logger.warn(`Failed to emit Claude activity: ${err}`);
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