Skip to content

Commit 78cf3d6

Browse files
Merge remote-tracking branch 'origin/main' into feature/debug-repeated-macos-7d7
# Conflicts: # docs/decisions/INDEX.md
2 parents 13fb026 + bc25df3 commit 78cf3d6

546 files changed

Lines changed: 38348 additions & 1247 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 233 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Open source, multi-provider, no telemetry, not tied to any cloud.
3636
- **Integrated workspace** - Built-in terminal, code editor with LSP, git changes panel, embedded vscode and chat in one IDE-like view
3737
- **Kanban task management** - Drag-and-drop boards, columns, and workflow automation
3838
- **Agentic workflows** - Multi-step pipelines that mix-and-match agents per step - for example, Claude Code Opus to design a plan, GitHub Copilot Sonnet to implement it, and Codex GPT 5.4 to review the changes. See [docs/workflow-tips.md](docs/workflow-tips.md)
39+
- **Automations** - Trigger agent tasks on schedules or webhooks, with configurable run destinations, context, and concurrency
3940
- **Sub-tasks** - Agents can spawn sub-tasks that resume from the parent task's session. Useful for splitting a task that has grown too big, or producing several PRs from the same starting point.
4041
- **CLI passthrough** - Drop into raw agent CLI mode for direct terminal interaction with agents that support it, leveraging their full native TUI
4142
- **Workspace isolation** - Git worktrees prevent concurrent agents from conflicting
@@ -44,6 +45,7 @@ Open source, multi-provider, no telemetry, not tied to any cloud.
4445
- **Runtime settings** - Executor profiles, secrets, custom prompts, utility agents, and resource metrics are configurable from Settings
4546
- **Task-agent MCP** - Agents can create subtasks, target sibling repos, attach extra branches for multiple PRs, message other tasks, read conversations, and inspect related tasks
4647
- **External MCP** - Manage Kandev from outside coding agents over streamable HTTP or SSE, with copyable config snippets for popular agent CLIs
48+
- **Plugin marketplace and management** - Browse, install, update, enable, disable, and configure plugins from Settings, including MCP Explorer and the [Bitbucket plugin](https://github.com/kdlbs/kandev-plugin-bitbucket)
4749
- **Workflow portability** - Export and import workflows as portable YAML across workspaces or Kandev installs
4850
- **Session management** - Resume and review agent conversations
4951
- **Shareable task snapshots** - Publish redacted task conversation snapshots as secret GitHub Gists, with preview and revoke controls
@@ -63,10 +65,11 @@ We're working on **Office mode**, a feature-flagged autonomy layer for persisten
6365
<a href="https://linear.app/"><img src="https://img.shields.io/badge/Linear-5E6AD2?style=for-the-badge&logo=linear&logoColor=white" alt="Linear"></a>
6466
<a href="https://sentry.io/"><img src="https://img.shields.io/badge/Sentry-362D59?style=for-the-badge&logo=sentry&logoColor=white" alt="Sentry"></a>
6567
<a href="https://gitlab.com/"><img src="https://img.shields.io/badge/GitLab-FC6D26?style=for-the-badge&logo=gitlab&logoColor=white" alt="GitLab"></a>
68+
<a href="https://azure.microsoft.com/products/devops"><img src="https://img.shields.io/badge/Azure%20DevOps-0078D7?style=for-the-badge&logo=azuredevops&logoColor=white" alt="Azure DevOps"></a>
6669
<a href="https://slack.com/"><img src="https://img.shields.io/badge/Slack-4A154B?style=for-the-badge&logo=slack&logoColor=white" alt="Slack"></a>
6770
</p>
6871

69-
Connect Kandev to the tools your team already uses — pull issues into the kanban, link tasks to PRs, and surface review activity inline.
72+
Connect Kandev to GitHub, GitLab, Jira, Linear, Sentry, and Azure DevOps to pull issues into the kanban, link tasks to PRs, and surface review activity inline. Bitbucket support is available through the [Bitbucket plugin](https://github.com/kdlbs/kandev-plugin-bitbucket).
7073

7174
## Supported ACP Agents
7275

apps/backend/cmd/plugin-fixture/main_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,9 @@ func (fakeHostTaskReader) Get(context.Context, string) (*pluginsdk.Task, error)
388388
func (fakeHostTaskReader) Update(context.Context, pluginsdk.UpdateTaskInput) (*pluginsdk.Task, error) {
389389
return nil, nil
390390
}
391+
func (fakeHostTaskReader) Move(context.Context, pluginsdk.MoveTaskInput) (*pluginsdk.MoveTaskOutcome, error) {
392+
return nil, nil
393+
}
391394

392395
func (r fakeHostTaskReader) Create(_ context.Context, in pluginsdk.CreateTaskInput) (*pluginsdk.Task, error) {
393396
r.h.lastCreateInput = in

apps/backend/config/prompts/config-context.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ MCP CONFIG TOOLS:
3939
- get_mcp_config_kandev: Get MCP server config for a profile. Required: profile_id.
4040
- update_mcp_config_kandev: Update MCP config for a profile. Required: profile_id. Optional: enabled, servers.
4141

42+
SAVED PROMPT TOOLS:
43+
- list_shared_prompts_kandev: List saved prompt summaries without content.
44+
- get_shared_prompt_kandev: Read one saved prompt by exact name. Required: name.
45+
Saved prompt names are case-sensitive. Surrounding whitespace is ignored. Use
46+
the list result to discover names before reading a prompt. These tools are
47+
read-only and do not create, update, delete, or expand saved prompts.
48+
4249
TASK TOOLS:
4350
- list_tasks_kandev: List all tasks in a workflow. Required: workflow_id.
4451
- move_task_kandev: Move a task to a different workflow step. Required: task_id, workflow_step_id.

apps/backend/internal/agent/docker/client.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"net"
1313
"net/netip"
1414
"strconv"
15+
"strings"
1516
"sync"
1617
"time"
1718

@@ -421,6 +422,11 @@ func (c *Client) RemoveContainer(ctx context.Context, containerID string, force
421422
// removed the container already, which is the desired end state.
422423
return nil
423424
}
425+
if containerRemovalInProgress(err) {
426+
// A concurrent cleanup owner is already removing the container. Treat
427+
// this conflict as success because both callers converge on removal.
428+
return nil
429+
}
424430
c.logger.Error("Failed to remove container", zap.String("container_id", containerID), zap.Error(err))
425431
return fmt.Errorf("failed to remove container %s: %w", containerID, err)
426432
}
@@ -429,6 +435,16 @@ func (c *Client) RemoveContainer(ctx context.Context, containerID string, force
429435
return nil
430436
}
431437

438+
func containerRemovalInProgress(err error) bool {
439+
if !errdefs.IsConflict(err) {
440+
return false
441+
}
442+
443+
message := strings.ToLower(err.Error())
444+
return strings.Contains(message, "removal of container") &&
445+
strings.Contains(message, "already in progress")
446+
}
447+
432448
func (c *Client) containerRemover() containerRemover {
433449
if c.remover != nil {
434450
return c.remover

apps/backend/internal/agent/docker/client_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,29 @@ func TestContainerTeardownTreatsMissingContainersAsSuccess(t *testing.T) {
166166
require.NoError(t, client.KillContainer(context.Background(), "missing-container", "SIGKILL"))
167167
require.NoError(t, client.RemoveContainer(context.Background(), "missing-container", true))
168168
}
169+
170+
func TestRemoveContainerTreatsConcurrentRemovalAsSuccess(t *testing.T) {
171+
dockerDaemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172+
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
173+
w.WriteHeader(http.StatusOK)
174+
return
175+
}
176+
w.Header().Set("Content-Type", "application/json")
177+
w.WriteHeader(http.StatusConflict)
178+
_, _ = w.Write([]byte(`{"message":"removal of container abc is already in progress"}`))
179+
}))
180+
t.Cleanup(dockerDaemon.Close)
181+
182+
log, err := logger.NewFromZap(zap.NewNop())
183+
require.NoError(t, err)
184+
client, err := NewClient(config.DockerConfig{
185+
Host: "tcp://" + strings.TrimPrefix(dockerDaemon.URL, "http://"),
186+
APIVersion: "1.44",
187+
}, log)
188+
require.NoError(t, err)
189+
190+
// Docker reports a conflict when another owner is already removing the
191+
// same container. Removal is a convergence operation, so the in-flight
192+
// owner has already taken responsibility for reaching the desired state.
193+
require.NoError(t, client.RemoveContainer(context.Background(), "abc", true))
194+
}

apps/backend/internal/agent/runtime/agentctl/launcher/launcher.go

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -518,21 +518,94 @@ func (l *Launcher) pipeOutput(name string, scanner *bufio.Scanner) {
518518
}
519519
}
520520

521-
// childLogLevel extracts the level token from a line emitted by the agentctl
522-
// child's console-format zap logger: "<ts>\t<LEVEL>\t<caller>\t<msg>", where
523-
// LEVEL is capitalized and may be wrapped in ANSI color codes. It returns the
524-
// uppercased level ("INFO"/"WARN"/…) or "" when the line does not match that
525-
// shape.
521+
// childLogLevel extracts a recognized level from the agentctl child's trusted
522+
// structured log formats. It returns the uppercased level ("INFO"/"WARN"/…)
523+
// or "" when the line does not match one of those formats.
526524
func childLogLevel(line string) string {
527525
// A well-formed console record is "<ts>\t<LEVEL>\t<caller>\t<msg>", so the
528526
// level token must be bounded by at least a following caller field. Requiring
529527
// three segments rejects truncated lines (e.g. "<ts>\t<token>") whose second
530528
// field is not actually a level, so they fall back to WARN.
531529
fields := strings.SplitN(line, "\t", 4)
532-
if len(fields) < 3 {
530+
if len(fields) >= 3 {
531+
if level := recognizedChildLogLevel(stripANSI(fields[1])); level != "" {
532+
return level
533+
}
534+
}
535+
536+
if level := childSlogTextLevel(line); level != "" {
537+
return level
538+
}
539+
540+
// slog.Default uses the standard log package's date/time prefix followed by
541+
// the level and message, for example "2026/08/12 10:00:00 INFO message".
542+
defaultFields := strings.Fields(line)
543+
if len(defaultFields) < 3 {
544+
return ""
545+
}
546+
if _, err := time.Parse("2006/01/02 15:04:05", defaultFields[0]+" "+defaultFields[1]); err != nil {
533547
return ""
534548
}
535-
level := strings.ToUpper(stripANSI(fields[1]))
549+
return recognizedChildLogLevel(stripANSI(defaultFields[2]))
550+
}
551+
552+
func childSlogTextLevel(line string) string {
553+
fields, ok := splitSlogTextFields(line)
554+
if !ok || len(fields) < 3 || !strings.HasPrefix(fields[0], "time=") ||
555+
!strings.HasPrefix(fields[1], "level=") {
556+
return ""
557+
}
558+
for _, field := range fields[2:] {
559+
if strings.HasPrefix(field, "msg=") {
560+
return recognizedChildLogLevel(stripANSI(strings.TrimPrefix(fields[1], "level=")))
561+
}
562+
}
563+
return ""
564+
}
565+
566+
func splitSlogTextFields(line string) ([]string, bool) {
567+
fields := make([]string, 0, 4)
568+
start := -1
569+
inQuotes := false
570+
escaped := false
571+
for i := 0; i < len(line); i++ {
572+
ch := line[i]
573+
if start < 0 {
574+
if ch == ' ' || ch == '\t' {
575+
continue
576+
}
577+
start = i
578+
}
579+
if inQuotes {
580+
switch {
581+
case escaped:
582+
escaped = false
583+
case ch == '\\':
584+
escaped = true
585+
case ch == '"':
586+
inQuotes = false
587+
}
588+
continue
589+
}
590+
switch ch {
591+
case '"':
592+
inQuotes = true
593+
case ' ', '\t':
594+
fields = append(fields, line[start:i])
595+
start = -1
596+
}
597+
}
598+
if inQuotes || escaped {
599+
return nil, false
600+
}
601+
if start >= 0 {
602+
fields = append(fields, line[start:])
603+
}
604+
return fields, true
605+
}
606+
607+
func recognizedChildLogLevel(level string) string {
608+
level = strings.ToUpper(level)
536609
switch level {
537610
case "DEBUG", "INFO", "WARN", "ERROR", "FATAL", "PANIC", "DPANIC":
538611
return level

apps/backend/internal/agent/runtime/agentctl/launcher/launcher_loglevel_test.go

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
package launcher
22

3-
import "testing"
3+
import (
4+
"bytes"
5+
stdlog "log"
6+
"log/slog"
7+
"strings"
8+
"testing"
9+
)
410

511
func TestChildLogLevel(t *testing.T) {
612
tests := []struct {
@@ -78,6 +84,98 @@ func TestChildLogLevel(t *testing.T) {
7884
}
7985
}
8086

87+
func TestChildLogLevelSlogTextRecords(t *testing.T) {
88+
tests := []struct {
89+
name string
90+
level string
91+
want string
92+
}{
93+
{name: "debug", level: "DEBUG", want: "DEBUG"},
94+
{name: "info", level: "INFO", want: "INFO"},
95+
{name: "warn", level: "WARN", want: "WARN"},
96+
{name: "error", level: "ERROR", want: "ERROR"},
97+
}
98+
for _, tt := range tests {
99+
t.Run(tt.name, func(t *testing.T) {
100+
line := "time=2026-08-12T10:00:00.000Z level=" + tt.level +
101+
" source=main.go:97 msg=\"agentctl record\""
102+
if got := childLogLevel(line); got != tt.want {
103+
t.Fatalf("childLogLevel(%q) = %q, want %q", line, got, tt.want)
104+
}
105+
})
106+
}
107+
}
108+
109+
func TestChildLogLevelSlogDefaultRecords(t *testing.T) {
110+
var output bytes.Buffer
111+
originalWriter := stdlog.Writer()
112+
originalFlags := stdlog.Flags()
113+
stdlog.SetOutput(&output)
114+
stdlog.SetFlags(stdlog.LstdFlags)
115+
t.Cleanup(func() {
116+
stdlog.SetOutput(originalWriter)
117+
stdlog.SetFlags(originalFlags)
118+
})
119+
120+
tests := []struct {
121+
name string
122+
write func()
123+
want string
124+
}{
125+
{name: "info", write: func() { slog.Default().Info("agentctl record", "component", "acp-conn") }, want: "INFO"},
126+
{name: "warn", write: func() { slog.Default().Warn("agentctl record", "component", "acp-conn") }, want: "WARN"},
127+
{name: "error", write: func() { slog.Default().Error("agentctl record", "component", "acp-conn") }, want: "ERROR"},
128+
}
129+
for _, tt := range tests {
130+
t.Run(tt.name, func(t *testing.T) {
131+
output.Reset()
132+
tt.write()
133+
line := strings.TrimSpace(output.String())
134+
if line == "" {
135+
t.Fatal("slog.Default produced no output")
136+
}
137+
if got := childLogLevel(line); got != tt.want {
138+
t.Fatalf("childLogLevel(%q) = %q, want %q", line, got, tt.want)
139+
}
140+
})
141+
}
142+
}
143+
144+
func TestChildLogLevelRejectsMalformedSlogTextRecords(t *testing.T) {
145+
tests := []struct {
146+
name string
147+
line string
148+
}{
149+
{
150+
name: "missing time anchor",
151+
line: "level=INFO source=main.go:97 msg=\"agentctl record\"",
152+
},
153+
{
154+
name: "missing level anchor",
155+
line: "time=2026-08-12T10:00:00.000Z msg=\"level=INFO\"",
156+
},
157+
{
158+
name: "missing message field",
159+
line: "time=2026-08-12T10:00:00.000Z level=INFO source=main.go:97",
160+
},
161+
{
162+
name: "level anchor is not second field",
163+
line: "time=2026-08-12T10:00:00.000Z source=main.go:97 level=INFO msg=\"record\"",
164+
},
165+
{
166+
name: "message-looking key is inside a quoted attribute",
167+
line: `time=2026-08-12T10:00:00.000Z level=INFO attr="foo msg=bar" component=acp-conn`,
168+
},
169+
}
170+
for _, tt := range tests {
171+
t.Run(tt.name, func(t *testing.T) {
172+
if got := childLogLevel(tt.line); got != "" {
173+
t.Fatalf("childLogLevel(%q) = %q, want no recognized level", tt.line, got)
174+
}
175+
})
176+
}
177+
}
178+
81179
func TestStripANSI(t *testing.T) {
82180
tests := []struct {
83181
name string

0 commit comments

Comments
 (0)