Files
multica/server/pkg/agent/kiro.go
Bohan Jiang a5a42846e6 fix(daemon): retry with a fresh session only when the resume was actually rejected (MUL-4966) (#5715)
* fix(daemon): gate fresh-session retry on tools executed, not session id (MUL-4966)

Switching provider accounts leaves the stored session id pointing at a
conversation the new account does not own. The daemon still passes it to
--resume, the provider rejects it, and the task dies before doing any work.

The existing fresh-session fallback was supposed to catch this but was
gated on `result.SessionID == ""`, which is not a lifecycle fact:

- Too narrow: a backend that echoes the requested id back when it rejects
  a resume keeps SessionID non-empty, so the fallback never fired — the
  reported bug.
- Too broad: a provider 401 before the first stream message also leaves
  SessionID empty, so an unrecoverable auth failure burned a second full
  run.

Gate on `tools == 0` instead. That states the property that actually makes
a retry safe — the agent executed no tool, so it mutated nothing, so
re-running cannot double-post a comment (comment creation has no
idempotency key and a duplicate re-fires its @mention triggers), reopen a
PR, or re-plan on top of its own half-finished work in the reused workdir.
Auth failures are excluded, mirroring retryableReasons in service/task.go.

The predicate is extracted to shouldRetryWithFreshSession so the tests
exercise production logic; both existing fallback tests re-implemented the
condition inline and would not have caught a regression in it.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): gate fresh-session retry on a positive resume-rejected signal (MUL-4966)

Review of the previous commit was right: `tools == 0` plus "not an auth
error" answers whether re-running is *safe*, not whether a new session can
*fix* the failure. Those are orthogonal, and answering the second by
exclusion inverts the burden of proof — the failures a fresh session cures
are a small enumerable set, while the ones it cannot are open-ended.

Concretely, the previous predicate fresh-retried on provider_network,
429/529, quota, 5xx and unclassified startup failures. provider_network is
the sharpest conflict: internal/service/task.go marks it resume-safe
(MUL-4910) specifically so the platform retry inherits the session and
continues the truncated conversation. Resetting the session first made that
contract unsatisfiable, silently discarding conversation context on a
transient blip — and rate limits got an immediate no-backoff re-run.

Replace the inference with positive evidence: agent.Result gains an explicit
ResumeRejected field, set only when a backend has proof the resume itself
was refused. claude/codebuddy/qwen derive it from resumeWasRejected, which
promotes the predicate resolveSessionID was already computing and encoding
as the side effect of blanking SessionID — using an empty string to carry
that meaning is what made the original bug possible. SessionID keeps being
dropped for a rejected resume (a dead pointer must not be persisted), but it
is no longer the signal the daemon reads to decide *why* a run failed.

The six ACP backends that recover from "session not found" set the flag at
the same points they already clear the id, so their existing recovery is not
caught by the narrower gate. codex needs nothing: thread/resume already falls
back to thread/start in-process, and deliberately does not on transport
errors.

Matching now includes the account-switch guardrail reported in #5704
(Claude Code 2.1.207, zh-CN): "400 此 session 已绑定另外的ai账号,请执行
/new 开启新 session". The en-US wording of the same guardrail has not been
captured yet, so those variants are marked inferred in the source; a miss
degrades to a terminal failure carrying the provider's raw text rather than
a mis-routed run.

Tests: backend-level fixtures drive ResumeRejected from real stream-json for
both the account-binding 400 and a network drop, and the predicate now
covers network/rate-limit/quota/5xx/auth/unclassified as explicit
non-retries.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): restore fresh-session recovery for backends with no rejection signal (MUL-4966)

Final review caught qwen regressing: its verified rejection string
("No saved session found with ID ...", already captured in
testdata/qwen-code-0.20.0-resume-not-found.stderr.txt) was not in the phrase
list, and qwen reports no session id on that path, so the new inclusion gate
turned a working auto-recovery into a terminal failure.

Auditing the other 17 resume-capable backends showed qwen was not alone.
antigravity, copilot, cursor, deveco and opencode all recovered from a
refused resume purely by reporting an empty SessionID, and none of them has
any rejection detection to convert into ResumeRejected — copilot's own
comment documents the hole (session.error before session.start), and
antigravity's helper returns "" when "the CLI exited before dispatching".
Making ResumeRejected the sole gate silently removed recovery from all five.

Fixing that by guessing rejection phrases for five more CLIs is the wrong
trade: no real output has been captured for any of them, and a false
positive discards a recoverable session pointer. So the gate is now two
tiers. Positive evidence (ResumeRejected) decides on its own where a backend
can produce it. Where none is available, an empty SessionID still gates the
retry — it proves no session was established, which is exactly what the
pre-change behaviour relied on — minus the classes a fresh session provably
cannot cure (network, rate limit, quota, provider 5xx, auth). That keeps the
resume-safe contract in internal/service/task.go intact while restoring what
these five backends had.

Also renames claudeResumeRejectedPhrases to resumeRejectedPhrases: it is
matched by claude, codebuddy and qwen, so a qwen-only string living under a
claude-prefixed name would be actively misleading.

Tests: qwen's existing missing-resume fixture now asserts ResumeRejected
(verified failing without the phrase), and the predicate covers the
no-signal tiers — retry when nothing was established, no retry once a
session exists or the failure classifies as uncurable.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): scope the no-signal fallback to backends that cannot detect rejections (MUL-4966)

Final review caught the compatibility path applying to every backend, not
just the five it was justified for. shouldRetryWithFreshSession only saw
(Result, priorSessionID, tools), so a false ResumeRejected could not be told
apart from a backend that has no way to answer — and claude/codebuddy/qwen/ACP
startup failures with no session id still fell through to the exclusion
branch. That contradicted both the stated intent and the function's own doc
comment ("where a backend can produce it, it is the whole answer").

Make the capability explicit. agent.ResumeRejectionUndetectable names the
five backends that scrape SessionID out of stream output and have no
rejection detection at all; the daemon takes provider and consults it, so a
capable backend reporting false is now taken at its word. Membership is
opt-in, so a new backend fails closed instead of silently inheriting a
guess-based retry.

Also completes the exclusion set: missing config, unavailable model, missing
executable, unsupported runtime version and (defensively) agent timeout all
have defined non-session remedies and were reaching `default: true`. What is
left through stays narrow — unknown, process failure, unparseable output,
context overflow — because a real rejection from these five most likely
surfaces as a non-zero exit or unparseable output, none of them reporting one
explicitly.

Tests: one identical result asserted across all five undetectable backends
(retries), twelve capable ones (no retry), and an unregistered provider
(fails closed), plus table cases for each newly excluded reason. Classifier
inputs were verified to map to the intended reasons rather than passing by
accident.

Also updates the ResumeRejected doc comment, which still said the daemon
gates on it alone.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-21 19:07:46 +08:00

590 lines
20 KiB
Go

package agent
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
)
// kiroBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args. `acp` is the protocol subcommand,
// and --trust-all-tools covers Kiro's CLI-level tool gate while
// hermesClient handles ACP session/request_permission auto-approval. In Kiro
// CLI 2.1.1, `-a` is short for --trust-all-tools, not --agent; --agent remains
// allowed so users can select a custom Kiro agent.
var kiroBlockedArgs = map[string]blockedArgMode{
"acp": blockedStandalone,
"-a": blockedStandalone,
"--trust-all-tools": blockedStandalone,
"--trust-tools": blockedWithValue,
}
// kiroBackend implements Backend by spawning `kiro-cli acp` and communicating
// via the standard ACP JSON-RPC 2.0 transport over stdin/stdout.
//
// Kiro CLI advertises loadSession, returns models from session/new, and supports
// session/set_model, so the existing Hermes/Kimi ACP client can drive it with
// only provider-specific launch and tool-name normalization.
type kiroBackend struct {
cfg Config
}
func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "kiro-cli"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("kiro executable not found at %q: %w", execPath, err)
}
// Translate the agent's mcp_config (Claude-style object of objects)
// into the array shape ACP `session/new` and `session/load` expect.
// Fail closed on malformed JSON so the launch surfaces the real error
// instead of silently dropping all MCP servers.
mcpServers, err := buildACPMcpServers(opts.McpConfig, b.cfg.Logger)
if err != nil {
return nil, fmt.Errorf("kiro: invalid mcp_config: %w", err)
}
timeout := opts.Timeout
runCtx, cancel := runContext(ctx, timeout)
kiroArgs := append([]string{"acp", "--trust-all-tools"}, filterCustomArgs(opts.CustomArgs, kiroBlockedArgs, b.cfg.Logger)...)
cmd := exec.CommandContext(runCtx, execPath, kiroArgs...)
hideAgentWindow(cmd)
b.cfg.Logger.Info("agent command", "exec", execPath, "args", kiroArgs)
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("kiro stdout pipe: %w", err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("kiro stdin pipe: %w", err)
}
// StderrPipe + an explicit copier give us a join point
// (`stderrDone`) that fires before the failure-promotion
// decision; see the matching comment in hermes.go for why the
// io.MultiWriter form races with stopReason=end_turn under load.
providerErr := newACPProviderErrorSniffer("kiro")
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("kiro stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start kiro: %w", err)
}
stderrSink := io.MultiWriter(newLogWriter(b.cfg.Logger, "[kiro:stderr] "), providerErr)
stderrDone := make(chan struct{})
go func() {
defer close(stderrDone)
_, _ = io.Copy(stderrSink, stderr)
}()
b.cfg.Logger.Info("kiro acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
var outputMu sync.Mutex
var output strings.Builder
var streamingCurrentTurn atomic.Bool
// Completion-preservation state for the -32603 close-handshake guard.
//
// Kiro raises `session/prompt -32603 "failed to generate a response"`
// when the model fails to produce its closing turn AFTER the task has
// already done its work. We keep such a run "completed" only when we
// have POSITIVE proof the finishing step succeeded — never from the mere
// absence of a result. A mid-command crash or internal session failure
// produces the very same "tool use, no result, -32603" shape, and
// treating that as success would mark a genuinely-unfinished task
// completed (see the review on #5511 / MUL-4860).
//
// The only positive proof available at this layer is a terminal
// ToolResult with status=="completed" for a finishing tool
// (goal_complete or `multica issue comment add`). We record the status
// of the MOST RECENT such result (ordered, keyed per CallID) so a later
// failed delivery correctly overrides an earlier success — e.g.
// "progress comment completed → final comment failed → -32603" must stay
// failed, while "first attempt failed → retry completed → -32603" is a
// real completion.
var goalCompleteCallIDs sync.Map
var issueCommentCallIDs sync.Map
var finishingMu sync.Mutex
var lastFinishingResultStatus string // "", "completed", or "failed"
promptDone := make(chan hermesPromptResult, 1)
c := &hermesClient{
cfg: b.cfg,
stdin: stdin,
pending: make(map[int]*pendingRPC),
pendingTools: make(map[string]*pendingToolCall),
acceptNotification: func(string) bool {
return streamingCurrentTurn.Load()
},
onMessage: func(msg Message) {
if !streamingCurrentTurn.Load() {
return
}
if msg.Type == MessageToolUse {
msg.Tool = kiroToolNameFromTitle(msg.Tool)
if msg.Tool == "goal_complete" && msg.CallID != "" {
goalCompleteCallIDs.Store(msg.CallID, struct{}{})
}
// Recognize `multica issue comment add` by its command
// payload regardless of how the adapter titled the tool.
// GPT-5.6 Sol may label the shell tool something other than
// the aliases kiroToolNameFromTitle folds into "terminal"
// (e.g. "execute"/"run"), so gating on msg.Tool=="terminal"
// here would miss it entirely (issue #5509). We still require
// a CallID so the tool use can be correlated to its result.
if msg.CallID != "" && isKiroIssueCommentAddTool(msg) {
issueCommentCallIDs.Store(msg.CallID, struct{}{})
}
}
if msg.Type == MessageToolResult {
_, isGoal := goalCompleteCallIDs.LoadAndDelete(msg.CallID)
_, isComment := issueCommentCallIDs.LoadAndDelete(msg.CallID)
if isGoal || isComment {
// Record this finishing tool's terminal outcome. The
// most recent result wins, so the guard sees the final
// delivery attempt's status and a later failure
// overrides an earlier success.
finishingMu.Lock()
lastFinishingResultStatus = msg.Status
finishingMu.Unlock()
}
}
if msg.Type == MessageText {
outputMu.Lock()
output.WriteString(msg.Content)
outputMu.Unlock()
}
trySend(msgCh, msg)
},
onPromptDone: func(result hermesPromptResult) {
if !streamingCurrentTurn.Load() {
return
}
select {
case promptDone <- result:
default:
}
},
}
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
c.handleLine(line)
}
c.closeAllPending(fmt.Errorf("kiro process exited"))
}()
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
defer func() {
stdin.Close()
_ = cmd.Wait()
}()
startTime := time.Now()
finalStatus := "completed"
var finalError string
var sessionID string
// Set when the ACP runtime refuses the session we asked to
// resume. Only that is curable by starting a fresh session, so
// handshake/network failures below must leave it false.
var resumeRejected bool
effectiveModel := strings.TrimSpace(opts.Model)
initResult, err := c.request(runCtx, "initialize", map[string]any{
"protocolVersion": 1,
"clientInfo": map[string]any{
"name": "multica-agent-sdk",
"version": "0.2.0",
},
"clientCapabilities": map[string]any{},
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("kiro initialize failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
// Drop MCP entries whose remote transport the runtime didn't
// advertise. See the matching comment in hermes.go for why
// unconditionally sending http/sse to a stdio-only ACP runtime
// tanks the whole session/new.
mcpServers = filterACPMcpServersByCapability(mcpServers, extractACPMcpCapabilities(initResult), "kiro", b.cfg.Logger)
cwd := opts.Cwd
if cwd == "" {
cwd = "."
}
if opts.ResumeSessionID != "" {
result, err := c.request(runCtx, "session/load", map[string]any{
"cwd": cwd,
"sessionId": opts.ResumeSessionID,
"mcpServers": mcpServers,
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("kiro session/load failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
// Apply the same defensive resolution kimi/hermes use: if
// kiro echoes a sessionId in the session/load response, prefer
// it (the canonical id the backend is committed to). When the
// response is empty or doesn't include sessionId — kiro's
// current observed shape — the helper falls back to the
// requested id, preserving today's behavior. Fixing this here
// too means a future kiro that DOES return a different id on
// silent state reset is handled the same way as hermes/kimi.
var changed bool
sessionID, changed = resolveResumedSessionID(opts.ResumeSessionID, result)
if changed {
b.cfg.Logger.Warn("agent returned a different session id on resume — original was likely lost; continuing with the new id",
"backend", "kiro",
"requested", opts.ResumeSessionID,
"actual", sessionID,
)
}
if effectiveModel == "" {
effectiveModel = extractACPCurrentModelID(result)
}
} else {
result, err := c.request(runCtx, "session/new", map[string]any{
"cwd": cwd,
"mcpServers": mcpServers,
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("kiro session/new failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
sessionID = extractACPSessionID(result)
if sessionID == "" {
finalStatus = "failed"
finalError = "kiro session/new returned no session ID"
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
if effectiveModel == "" {
effectiveModel = extractACPCurrentModelID(result)
}
}
c.sessionID = sessionID
b.cfg.Logger.Info("kiro session created", "session_id", sessionID)
if opts.Model != "" {
if _, err := c.request(runCtx, "session/set_model", map[string]any{
"sessionId": sessionID,
"modelId": opts.Model,
}); err != nil {
b.cfg.Logger.Warn("kiro set_session_model failed", "error", err, "requested_model", opts.Model)
finalStatus = "failed"
finalError = fmt.Sprintf("kiro could not switch to model %q: %v", opts.Model, err)
if opts.ResumeSessionID != "" && isACPSessionNotFound(err) {
// On a resumed session with a model override, the dead
// session surfaces here instead of at session/prompt.
// Same fix as the prompt path below: clear the id so
// the daemon's resume-failure fallback retries fresh.
b.cfg.Logger.Warn("resumed session not found at set_model time; clearing session id so the daemon retries fresh",
"backend", "kiro",
"session_id", sessionID,
)
sessionID = ""
resumeRejected = true
}
resCh <- Result{
Status: finalStatus,
Error: finalError,
DurationMs: time.Since(startTime).Milliseconds(),
SessionID: sessionID,
ResumeRejected: resumeRejected,
}
return
}
b.cfg.Logger.Info("kiro session model set", "model", opts.Model)
}
userText := prompt
if opts.SystemPrompt != "" {
userText = opts.SystemPrompt + "\n\n---\n\n" + prompt
}
promptBlocks := []map[string]any{
{"type": "text", "text": userText},
}
// Kiro's published docs use `content`, while Kiro CLI 2.1.1 still
// requires the standard ACP `prompt` field. Send both so either wire
// shape can drive the turn.
// TODO: drop one field once Kiro lands on a single canonical payload.
streamingCurrentTurn.Store(true)
_, err = c.request(runCtx, "session/prompt", map[string]any{
"sessionId": sessionID,
"content": promptBlocks,
"prompt": promptBlocks,
})
if err != nil {
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("kiro timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else {
finalStatus = "failed"
finalError = fmt.Sprintf("kiro session/prompt failed: %v", err)
// Preserve completion only on POSITIVE proof: the most
// recent finishing-tool ToolResult we observed was
// status=="completed", paired with the specific -32603 close
// handshake. A missing result is NOT proof — a mid-command
// crash produces the same result-less shape (Must-fix #1) —
// and because we key on the most recent outcome, a later
// failed delivery overrides an earlier success (Must-fix #2).
finishingMu.Lock()
lastFinishing := lastFinishingResultStatus
finishingMu.Unlock()
if lastFinishing == "completed" && isKiroGoalCompleteCloseError(err) {
b.cfg.Logger.Warn("kiro session/prompt failed after a completed finishing-tool result; preserving completed task status", "error", err)
finalStatus = "completed"
finalError = ""
} else if opts.ResumeSessionID != "" && isACPSessionNotFound(err) {
// See the hermes backend: the runtime echoes the
// requested id back from session/resume even when
// the session is gone, so the stale id only fails
// here, at prompt time. Empty SessionID lets the
// daemon's resume-failure fallback retry fresh and
// store the replacement id.
b.cfg.Logger.Warn("resumed session not found at prompt time; clearing session id so the daemon retries fresh",
"backend", "kiro",
"session_id", sessionID,
)
sessionID = ""
resumeRejected = true
}
}
} else {
select {
case pr := <-promptDone:
if pr.stopReason == "cancelled" {
finalStatus = "aborted"
finalError = "kiro cancelled the prompt"
}
c.usageMu.Lock()
c.usage.InputTokens += pr.usage.InputTokens
c.usage.OutputTokens += pr.usage.OutputTokens
c.usage.CacheReadTokens += pr.usage.CacheReadTokens
c.usage.CacheWriteTokens += pr.usage.CacheWriteTokens
c.usageMu.Unlock()
default:
}
}
duration := time.Since(startTime)
b.cfg.Logger.Info("kiro finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
stdin.Close()
cancel()
<-readerDone
// Ensure the stderr copier has drained before consulting the
// provider-error sniffer; see hermes.go for the failure mode.
<-stderrDone
outputMu.Lock()
finalOutput := output.String()
outputMu.Unlock()
// Promote completed→failed when stderr or the agent text
// stream show a terminal upstream-LLM failure (HTTP 4xx /
// rate-limit / expired token). See the helper docs for the
// full signal set; the key safety property is that transient
// per-attempt warnings followed by a successful retry stay
// "completed".
finalStatus, finalError = promoteACPResultOnProviderError(finalStatus, finalError, finalOutput, providerErr)
c.usageMu.Lock()
u := c.usage
c.usageMu.Unlock()
var usageMap map[string]TokenUsage
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
model := effectiveModel
if model == "" {
model = "unknown"
}
usageMap = map[string]TokenUsage{model: u}
}
resCh <- Result{
Status: finalStatus,
Output: finalOutput,
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
ResumeRejected: resumeRejected,
Usage: usageMap,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
func isKiroGoalCompleteCloseError(err error) bool {
var rpcErr *acpRPCError
if !errors.As(err, &rpcErr) {
return false
}
if rpcErr.Method != "session/prompt" || rpcErr.Code != -32603 {
return false
}
if !strings.EqualFold(strings.TrimSpace(rpcErr.Message), "Internal error") {
return false
}
return strings.Contains(strings.ToLower(rpcErr.Data), "failed to generate a response")
}
// isKiroIssueCommentAddTool reports whether a tool-use message is a
// `multica issue comment add` invocation. It keys purely off the command
// payload, not the normalized tool name: GPT-5.6 Sol adapters may title the
// shell tool with a name that doesn't fold into "terminal" (the earlier
// msg.Tool=="terminal" gate silently dropped those and left completed tasks
// marked failed — see #5509). isKiroIssueCommentAddCommand is strict enough
// that no non-shell tool's input will accidentally match.
func isKiroIssueCommentAddTool(msg Message) bool {
command, _ := msg.Input["command"].(string)
return isKiroIssueCommentAddCommand(command)
}
func isKiroIssueCommentAddCommand(command string) bool {
parts := trimLeadingEnvAssignments(strings.Fields(command))
// Some runtimes route terminal calls through a shell wrapper such as
// `sh -c "multica issue comment add ..."`; unwrap a single such layer so
// the real invocation is still recognized.
if len(parts) >= 3 && isPOSIXShellName(parts[0]) && parts[1] == "-c" {
inner := strings.Trim(strings.Join(parts[2:], " "), "\"'")
parts = trimLeadingEnvAssignments(strings.Fields(inner))
}
if len(parts) < 4 {
return false
}
executable := strings.TrimPrefix(parts[0], "./")
if executable != "multica" && !strings.HasSuffix(executable, "/multica") {
return false
}
return parts[1] == "issue" && parts[2] == "comment" && parts[3] == "add"
}
// trimLeadingEnvAssignments drops leading `KEY=VALUE` tokens so an invocation
// like `MULTICA_TOKEN=x multica issue comment add ...` is still recognized.
func trimLeadingEnvAssignments(parts []string) []string {
for len(parts) > 0 && isEnvAssignment(parts[0]) {
parts = parts[1:]
}
return parts
}
// isEnvAssignment reports whether tok is a `NAME=value` shell env assignment.
func isEnvAssignment(tok string) bool {
eq := strings.IndexByte(tok, '=')
if eq <= 0 {
return false
}
for i := 0; i < eq; i++ {
c := tok[i]
switch {
case c == '_', c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z':
case i > 0 && c >= '0' && c <= '9':
default:
return false
}
}
return true
}
// isPOSIXShellName reports whether tok names a shell that takes `-c <command>`.
func isPOSIXShellName(tok string) bool {
if i := strings.LastIndexByte(tok, '/'); i >= 0 {
tok = tok[i+1:]
}
switch tok {
case "sh", "bash", "zsh", "dash":
return true
default:
return false
}
}
func kiroToolNameFromTitle(title string) string {
t := strings.TrimSpace(title)
if t == "" {
return ""
}
if idx := strings.Index(t, ":"); idx > 0 {
t = strings.TrimSpace(t[:idx])
}
lower := strings.ToLower(t)
switch lower {
case "read", "read file":
return "read_file"
case "write", "write file":
return "write_file"
case "edit", "patch":
return "edit_file"
case "shell", "bash", "terminal", "run command", "run shell command":
return "terminal"
case "grep", "search", "find":
return "search_files"
case "glob":
return "glob"
case "code":
return "code"
case "web search":
return "web_search"
case "fetch", "web fetch":
return "web_fetch"
case "todo", "todo write", "todo list", "todo_list":
return "todo_write"
}
return strings.ReplaceAll(lower, " ", "_")
}