@swarmclawai/swarmclaw 1.6.0 → 1.6.1

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 CHANGED
@@ -399,6 +399,17 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.6.1 Highlights
403
+
404
+ Follow-up release for v1.6 with workflow starts, safer metadata handling, A2A discovery polish, and [#61](https://github.com/swarmclawai/swarmclaw/pull/61) by [@latentwill](https://github.com/latentwill). Thanks latentwill!
405
+
406
+ - **Mission and protocol templates for real work.** New starter paths cover codebase review sprints, research bureau scans, content studio cycles, release readiness panels, synthesis panels, and builder review loops.
407
+ - **Home launchpad paths.** First-run users can choose a self-hosted assistant, visual workflow, or autonomous mission path, with quality actions still one click away.
408
+ - **A2A discovery is easier to integrate.** The canonical `/.well-known/agent-card.json` endpoint now works alongside the legacy API route and hides disabled or trashed agents from public discovery.
409
+ - **Internal metadata stripping is safer.** Side-channel JSON is removed with balanced-object parsing and zod validation so nested payloads are scrubbed without deleting ordinary user JSON.
410
+ - **Browser smoke gate restored.** `npm run test:e2e` now runs a Playwright smoke against health, A2A discovery, `/home`, and `/quality`, either against a live URL or a temporary local dev server.
411
+ - **OpenCode CLI hang fixed.** OpenCode CLI delegation no longer keeps an inherited stdin pipe open, preventing hangs in non-interactive runs.
412
+
402
413
  ### v1.6.0 Highlights
403
414
 
404
415
  Operator Quality Center release for builders running autonomous agents in production-like workflows.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -87,9 +87,9 @@
87
87
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
88
88
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
89
89
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
90
- "test:runtime": "tsx --test src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
90
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/providers/opencode-cli.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
91
91
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
92
- "test:e2e": "tsx .workbench/browser-e2e/run.ts",
92
+ "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
93
93
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
94
94
  "electron:compile": "tsc -p electron/tsconfig.json",
95
95
  "electron:dev": "npm run electron:compile && electron electron-dist/main.js",
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { buildAgentCardDiscoveryPayload } from '@/lib/a2a/agent-card'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ /**
7
+ * GET /.well-known/agent-card.json?agentId=xxx
8
+ *
9
+ * Canonical public A2A Agent Card discovery endpoint. If agentId is omitted,
10
+ * returns a directory of discoverable local SwarmClaw agents.
11
+ */
12
+ export async function GET(req: Request) {
13
+ const { body, status } = buildAgentCardDiscoveryPayload(req)
14
+ return NextResponse.json(body, { status })
15
+ }
@@ -1,46 +1,15 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { getAgent, listAgents } from '@/lib/server/agents/agent-repository'
3
- import { generateAgentCard } from '@/lib/a2a/agent-card'
2
+ import { buildAgentCardDiscoveryPayload } from '@/lib/a2a/agent-card'
4
3
 
5
4
  export const dynamic = 'force-dynamic'
6
5
 
7
6
  /**
8
- * GET /.well-known/agent-card.json?agentId=xxx
7
+ * GET /api/.well-known/agent-card?agentId=xxx
9
8
  *
10
- * A2A Agent Card discovery endpoint.
11
- * If agentId is provided, returns the full card for that agent.
12
- * Otherwise, returns a directory of all non-disabled agents.
13
- *
14
- * Publicly accessible per A2A spec — no auth required for discovery.
9
+ * Back-compatible A2A Agent Card discovery endpoint. The canonical public
10
+ * well-known URL is implemented at /.well-known/agent-card.json.
15
11
  */
16
12
  export async function GET(req: Request) {
17
- const { searchParams } = new URL(req.url)
18
- const agentId = searchParams.get('agentId')
19
- const baseUrl = `${new URL(req.url).origin}`
20
-
21
- if (agentId) {
22
- const agent = getAgent(agentId)
23
- if (!agent) {
24
- return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
25
- }
26
- if (agent.disabled) {
27
- return NextResponse.json({ error: 'Agent is disabled' }, { status: 404 })
28
- }
29
- const card = generateAgentCard(agent, baseUrl)
30
- return NextResponse.json(card)
31
- }
32
-
33
- // Return directory of all active agents
34
- const agents = listAgents()
35
- const directory = Object.values(agents)
36
- .filter(a => !a.disabled)
37
- .map(a => ({
38
- name: a.name,
39
- description: a.description || `SwarmClaw agent: ${a.name}`,
40
- agentId: a.id,
41
- apiEndpoint: `${baseUrl}/api/a2a`,
42
- cardUrl: `${baseUrl}/api/.well-known/agent-card?agentId=${a.id}`,
43
- }))
44
-
45
- return NextResponse.json({ agents: directory, protocolVersion: '0.3.0' })
13
+ const { body, status } = buildAgentCardDiscoveryPayload(req)
14
+ return NextResponse.json(body, { status })
46
15
  }
@@ -14,6 +14,48 @@ function SnapshotItem({ label, value, hint }: { label: string; value: string; hi
14
14
  )
15
15
  }
16
16
 
17
+ function PathCard({
18
+ kicker,
19
+ title,
20
+ description,
21
+ primaryLabel,
22
+ secondaryLabel,
23
+ onPrimary,
24
+ onSecondary,
25
+ }: {
26
+ kicker: string
27
+ title: string
28
+ description: string
29
+ primaryLabel: string
30
+ secondaryLabel: string
31
+ onPrimary: () => void
32
+ onSecondary: () => void
33
+ }) {
34
+ return (
35
+ <div className="flex min-h-[220px] flex-col rounded-[18px] border border-white/[0.07] bg-white/[0.03] p-5">
36
+ <div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/55">{kicker}</div>
37
+ <div className="mt-3 text-[18px] font-display font-700 tracking-normal text-text">{title}</div>
38
+ <p className="mt-2 flex-1 text-[13px] leading-relaxed text-text-3/72">{description}</p>
39
+ <div className="mt-5 flex flex-wrap gap-2">
40
+ <button
41
+ type="button"
42
+ onClick={onPrimary}
43
+ className="rounded-[10px] bg-accent-bright px-3.5 py-2 text-[12px] font-display font-700 text-black transition-opacity hover:opacity-90"
44
+ >
45
+ {primaryLabel}
46
+ </button>
47
+ <button
48
+ type="button"
49
+ onClick={onSecondary}
50
+ className="rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3.5 py-2 text-[12px] font-display font-700 text-text-2 transition-colors hover:bg-white/[0.08]"
51
+ >
52
+ {secondaryLabel}
53
+ </button>
54
+ </div>
55
+ </div>
56
+ )
57
+ }
58
+
17
59
  type Props = {
18
60
  firstAgent: Agent | null
19
61
  agentCount: number
@@ -53,17 +95,17 @@ export function HomeLaunchpad({
53
95
  }: Props) {
54
96
  return (
55
97
  <div className="max-w-[980px] mx-auto px-6 py-10">
56
- <div className="rounded-[24px] border border-white/[0.06] bg-gradient-to-br from-white/[0.05] via-white/[0.02] to-transparent p-6">
98
+ <div className="rounded-[20px] border border-white/[0.06] bg-white/[0.025] p-6">
57
99
  <div className="inline-flex rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-[11px] font-700 uppercase tracking-[0.16em] text-text-3/70">
58
- Launchpad
100
+ v1.6 Launchpad
59
101
  </div>
60
102
  <div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
61
103
  <div className="max-w-[620px]">
62
- <h1 className="font-display text-[34px] font-700 tracking-[-0.03em] text-text">
63
- Start with the result you want, not the control plane.
104
+ <h1 className="font-display text-[34px] font-700 tracking-normal text-text">
105
+ Pick a path and watch the workspace move.
64
106
  </h1>
65
107
  <p className="mt-3 text-[15px] leading-relaxed text-text-3/72">
66
- SwarmClaw already has the building blocks. Use this workspace to start a live agent chat, launch a bounded session, wire a connector, or move straight into reusable workflows.
108
+ Start with a local assistant, a reusable workflow, or a budgeted autonomous mission. The rest of the control plane stays one click away.
67
109
  </p>
68
110
  </div>
69
111
  <div className="rounded-[18px] border border-white/[0.06] bg-white/[0.03] p-4 min-w-[240px]">
@@ -94,6 +136,36 @@ export function HomeLaunchpad({
94
136
  </div>
95
137
  </div>
96
138
 
139
+ <div className="mt-6 grid gap-3 lg:grid-cols-3">
140
+ <PathCard
141
+ kicker="Self-hosted assistant"
142
+ title={firstAgent ? `Work with ${firstAgent.name}` : 'Create the first agent'}
143
+ description="Open a live agent chat, then add memory, local tools, provider routing, or connector access as the work demands."
144
+ primaryLabel={firstAgent ? 'Open Chat' : 'Open Agents'}
145
+ secondaryLabel="Connect Platform"
146
+ onPrimary={onOpenFirstAgent}
147
+ onSecondary={onOpenConnectors}
148
+ />
149
+ <PathCard
150
+ kicker="Visual workflow"
151
+ title="Shape a reusable run"
152
+ description="Use protocol templates and the builder to turn review, research, planning, or release checks into durable workflows."
153
+ primaryLabel="Open Builder"
154
+ secondaryLabel="Use Templates"
155
+ onPrimary={onOpenBuilder}
156
+ onSecondary={onOpenProtocols}
157
+ />
158
+ <PathCard
159
+ kicker="Autonomous mission"
160
+ title="Run with budgets"
161
+ description="Start a mission template for release QA, research, support triage, cost audit, or failed-run review with reports and caps."
162
+ primaryLabel="Open Missions"
163
+ secondaryLabel="Quality Center"
164
+ onPrimary={onStartReleaseQaMission}
165
+ onSecondary={onRunEvalSuite}
166
+ />
167
+ </div>
168
+
97
169
  <div className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
98
170
  <LaunchActionCard
99
171
  title={firstAgent ? 'Open First Agent Chat' : 'Open Agents'}
@@ -0,0 +1,94 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { after, before, describe, it } from 'node:test'
6
+
7
+ const originalEnv = {
8
+ DATA_DIR: process.env.DATA_DIR,
9
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
10
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
11
+ }
12
+
13
+ let tempDir = ''
14
+ let storage: typeof import('@/lib/server/storage')
15
+ let canonicalRoute: typeof import('@/app/.well-known/agent-card.json/route')
16
+ let legacyRoute: typeof import('@/app/api/.well-known/agent-card/route')
17
+
18
+ function testAgent(id: string, overrides: Record<string, unknown> = {}) {
19
+ const now = Date.now()
20
+ return {
21
+ id,
22
+ name: id === 'agent-active' ? 'Active Agent' : 'Hidden Agent',
23
+ description: 'A2A route test agent',
24
+ systemPrompt: '',
25
+ provider: 'ollama',
26
+ model: 'qwen3.5',
27
+ credentialId: null,
28
+ fallbackCredentialIds: [],
29
+ apiEndpoint: null,
30
+ gatewayProfileId: null,
31
+ extensions: [],
32
+ capabilities: ['research'],
33
+ createdAt: now,
34
+ updatedAt: now,
35
+ ...overrides,
36
+ }
37
+ }
38
+
39
+ before(async () => {
40
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-a2a-card-'))
41
+ process.env.DATA_DIR = path.join(tempDir, 'data')
42
+ process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
43
+ process.env.SWARMCLAW_BUILD_MODE = '1'
44
+ storage = await import('@/lib/server/storage')
45
+ canonicalRoute = await import('@/app/.well-known/agent-card.json/route')
46
+ legacyRoute = await import('@/app/api/.well-known/agent-card/route')
47
+ storage.saveAgents({
48
+ 'agent-active': testAgent('agent-active'),
49
+ 'agent-disabled': testAgent('agent-disabled', { disabled: true }),
50
+ 'agent-trashed': testAgent('agent-trashed', { trashedAt: Date.now() }),
51
+ })
52
+ })
53
+
54
+ after(() => {
55
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
56
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
57
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
58
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
59
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
60
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
61
+ fs.rmSync(tempDir, { recursive: true, force: true })
62
+ })
63
+
64
+ describe('A2A agent card discovery', () => {
65
+ it('serves the canonical well-known directory and hides disabled agents', async () => {
66
+ const response = await canonicalRoute.GET(new Request('http://local.test/.well-known/agent-card.json'))
67
+ assert.equal(response.status, 200)
68
+ const body = await response.json()
69
+
70
+ assert.equal(body.protocolVersion, '0.3.0')
71
+ assert.equal(body.kind, 'directory')
72
+ assert.deepEqual(body.agents.map((agent: { agentId: string }) => agent.agentId), ['agent-active'])
73
+ assert.equal(body.agents[0].apiEndpoint, 'http://local.test/api/a2a')
74
+ assert.equal(body.agents[0].cardUrl, 'http://local.test/.well-known/agent-card.json?agentId=agent-active')
75
+ })
76
+
77
+ it('returns a full card from both canonical and legacy routes', async () => {
78
+ const canonical = await canonicalRoute.GET(new Request('http://local.test/.well-known/agent-card.json?agentId=agent-active'))
79
+ const legacy = await legacyRoute.GET(new Request('http://local.test/api/.well-known/agent-card?agentId=agent-active'))
80
+
81
+ assert.equal(canonical.status, 200)
82
+ assert.equal(legacy.status, 200)
83
+ assert.equal((await canonical.json()).name, 'Active Agent')
84
+ assert.equal((await legacy.json()).apiEndpoint, 'http://local.test/api/a2a')
85
+ })
86
+
87
+ it('returns 404 for disabled or missing agent cards', async () => {
88
+ const disabled = await canonicalRoute.GET(new Request('http://local.test/.well-known/agent-card.json?agentId=agent-disabled'))
89
+ const missing = await canonicalRoute.GET(new Request('http://local.test/.well-known/agent-card.json?agentId=nope'))
90
+
91
+ assert.equal(disabled.status, 404)
92
+ assert.equal(missing.status, 404)
93
+ })
94
+ })
@@ -1,7 +1,8 @@
1
1
  import type { Agent } from '@/types/agent'
2
2
  import type { AgentCard } from './types'
3
+ import { getAgent, listAgents } from '@/lib/server/agents/agent-repository'
3
4
 
4
- const A2A_PROTOCOL_VERSION = '0.3.0'
5
+ export const A2A_PROTOCOL_VERSION = '0.3.0'
5
6
  const SWARMCLAW_VERSION = '1.0.0'
6
7
 
7
8
  /**
@@ -59,3 +60,42 @@ export function generateAgentCard(agent: Agent, baseUrl: string): AgentCard {
59
60
  ],
60
61
  }
61
62
  }
63
+
64
+ function isPubliclyDiscoverableAgent(agent: Agent): boolean {
65
+ return agent.disabled !== true && !agent.trashedAt
66
+ }
67
+
68
+ export function buildAgentCardDiscoveryPayload(req: Request): {
69
+ body: unknown
70
+ status?: number
71
+ } {
72
+ const url = new URL(req.url)
73
+ const agentId = url.searchParams.get('agentId')
74
+ const baseUrl = url.origin
75
+
76
+ if (agentId) {
77
+ const agent = getAgent(agentId)
78
+ if (!agent || !isPubliclyDiscoverableAgent(agent)) {
79
+ return { body: { error: 'Agent not found' }, status: 404 }
80
+ }
81
+ return { body: generateAgentCard(agent, baseUrl) }
82
+ }
83
+
84
+ const directory = Object.values(listAgents())
85
+ .filter(isPubliclyDiscoverableAgent)
86
+ .map((agent) => ({
87
+ name: agent.name,
88
+ description: agent.description || `SwarmClaw agent: ${agent.name}`,
89
+ agentId: agent.id,
90
+ apiEndpoint: `${baseUrl}/api/a2a`,
91
+ cardUrl: `${baseUrl}/.well-known/agent-card.json?agentId=${encodeURIComponent(agent.id)}`,
92
+ }))
93
+
94
+ return {
95
+ body: {
96
+ agents: directory,
97
+ kind: 'directory',
98
+ protocolVersion: A2A_PROTOCOL_VERSION,
99
+ },
100
+ }
101
+ }
@@ -0,0 +1,9 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { OPENCODE_CLI_STDIO } from './opencode-cli'
4
+
5
+ describe('opencode-cli provider', () => {
6
+ it('closes child stdin so argv-prompt runs do not hang waiting for input', () => {
7
+ assert.deepEqual(OPENCODE_CLI_STDIO, ['ignore', 'pipe', 'pipe'])
8
+ })
9
+ })
@@ -4,6 +4,8 @@ import { log } from '../server/logger'
4
4
  import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
5
5
  import { resolveCliBinary, buildCliEnv, probeCliAuth, attachAbortHandler, isStderrNoise } from './cli-utils'
6
6
 
7
+ export const OPENCODE_CLI_STDIO: ['ignore', 'pipe', 'pipe'] = ['ignore', 'pipe', 'pipe']
8
+
7
9
  /**
8
10
  * OpenCode CLI provider — spawns `opencode run <message> --format json` for non-interactive usage.
9
11
  * Tracks `session.opencodeSessionId` from streamed JSON events to support multi-turn continuity.
@@ -60,7 +62,9 @@ export function streamOpenCodeCliChat({ session, message, imagePath, systemPromp
60
62
  const proc = spawn(binary, args, {
61
63
  cwd,
62
64
  env,
63
- stdio: ['pipe', 'pipe', 'pipe'],
65
+ // stdin must be closed: OpenCode CLI can wait forever on a connected pipe
66
+ // even when the prompt is passed via argv.
67
+ stdio: OPENCODE_CLI_STDIO,
64
68
  timeout: processTimeoutMs,
65
69
  })
66
70
 
@@ -96,6 +96,23 @@ describe('mission-templates: registry', () => {
96
96
  assert.equal(templates.getMissionTemplate('weekly-agent-quality-report')?.category, 'monitoring')
97
97
  })
98
98
 
99
+ it('includes v1.6 love-path templates for review, research, and content', () => {
100
+ const expected = [
101
+ ['codebase-review-sprint', 'productivity'],
102
+ ['research-bureau-scan', 'research'],
103
+ ['content-studio-cycle', 'communication'],
104
+ ] as const
105
+
106
+ for (const [id, category] of expected) {
107
+ const template = templates.getMissionTemplate(id)
108
+ assert.ok(template, `expected ${id} template`)
109
+ assert.equal(template.category, category)
110
+ assert.ok(template.defaults.goal.length > 120, `${id} should have a concrete goal`)
111
+ assert.ok(template.defaults.successCriteria.length >= 3, `${id} should have acceptance criteria`)
112
+ assert.ok(template.defaults.budget.maxTurns, `${id} should have a turn budget`)
113
+ }
114
+ })
115
+
99
116
  it('getMissionTemplate resolves known ids', () => {
100
117
  const list = templates.listMissionTemplates()
101
118
  const first = list[0]
@@ -303,6 +303,75 @@ export const BUILT_IN_MISSION_TEMPLATES: MissionTemplate[] = [
303
303
  reportSchedule: report(DAY),
304
304
  },
305
305
  },
306
+ {
307
+ id: 'codebase-review-sprint',
308
+ name: 'Codebase Review Sprint',
309
+ description:
310
+ 'Inspect a repository for user-facing bugs, fragile flows, missing tests, and release-readiness risks.',
311
+ icon: '🧪',
312
+ category: 'productivity',
313
+ tags: ['codebase', 'review', 'release', 'quality'],
314
+ setupNote:
315
+ 'Set the repository path and risk areas in the goal. Keep code edits disabled unless the mission is explicitly converted into implementation work.',
316
+ defaults: {
317
+ title: 'Codebase Review Sprint',
318
+ goal:
319
+ 'Review the current codebase for release-readiness. Inspect tests, build scripts, recent failure-prone flows, user-facing onboarding, desktop/package notes, and high-risk runtime paths. Produce a prioritized markdown report with bugs, missing tests, quick wins, and deferred risks. Do not edit files unless explicitly approved.',
320
+ successCriteria: [
321
+ 'At least 5 concrete risks or no-finding checks are documented with file or workflow evidence',
322
+ 'Recommended fixes are prioritized by user impact and implementation effort',
323
+ 'The report separates release blockers from follow-up improvements',
324
+ ],
325
+ budget: budget({ maxUsd: 2, maxTokens: 140_000, maxTurns: 140, maxWallclockSec: DAY }),
326
+ reportSchedule: report(6 * HOUR),
327
+ },
328
+ },
329
+ {
330
+ id: 'research-bureau-scan',
331
+ name: 'Research Bureau Scan',
332
+ description:
333
+ 'Fan out a topic across multiple research angles, then synthesize evidence into a concise decision brief.',
334
+ icon: '🔎',
335
+ category: 'research',
336
+ tags: ['research', 'synthesis', 'competitive', 'decision'],
337
+ setupNote:
338
+ 'Name the topic, sources, and decision the research should support before starting.',
339
+ defaults: {
340
+ title: 'Research Bureau Scan',
341
+ goal:
342
+ 'Research the target topic from at least three angles: current market signals, technical feasibility, and user impact. Gather source-backed notes, compare conflicting evidence, and produce a concise decision brief with recommendation, confidence, and open questions.',
343
+ successCriteria: [
344
+ 'At least 6 source-backed findings are captured',
345
+ 'The final brief compares evidence across at least 3 research angles',
346
+ 'Recommendation includes confidence level and open questions',
347
+ ],
348
+ budget: budget({ maxUsd: 3, maxTokens: 180_000, maxTurns: 180, maxWallclockSec: 2 * DAY }),
349
+ reportSchedule: report(12 * HOUR),
350
+ },
351
+ },
352
+ {
353
+ id: 'content-studio-cycle',
354
+ name: 'Content Studio Cycle',
355
+ description:
356
+ 'Turn a brief into draft, edit pass, publish checklist, and repurposed snippets for multiple channels.',
357
+ icon: '✍️',
358
+ category: 'communication',
359
+ tags: ['content', 'writing', 'editorial', 'launch'],
360
+ setupNote:
361
+ 'Provide the audience, voice, channels, and any approval boundary. Public posting stays manual by default.',
362
+ defaults: {
363
+ title: 'Content Studio Cycle',
364
+ goal:
365
+ 'Convert the supplied brief into a polished content package. Produce an outline, long-form draft, editor notes, publish checklist, and short repurposed snippets for the requested channels. Do not publish or post externally without approval.',
366
+ successCriteria: [
367
+ 'Package includes outline, draft, editor notes, checklist, and channel snippets',
368
+ 'Copy follows the requested audience and voice constraints',
369
+ 'Any claims that need evidence are marked before publication',
370
+ ],
371
+ budget: budget({ maxUsd: 2, maxTokens: 120_000, maxTurns: 120, maxWallclockSec: DAY }),
372
+ reportSchedule: report(6 * HOUR),
373
+ },
374
+ },
306
375
  {
307
376
  id: 'hello-world-demo',
308
377
  name: 'Hello World Demo',
@@ -2,6 +2,31 @@ import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
  import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
4
4
 
5
+ test('protocol-service exposes v1.6 built-in templates for release, research, and builder flows', () => {
6
+ const output = runWithTempDataDir<{
7
+ ids: string[]
8
+ releaseOutputs: string[]
9
+ builderKinds: string[]
10
+ }>(`
11
+ const protocolsMod = await import('./src/lib/server/protocols/protocol-templates')
12
+ const protocols = protocolsMod.default || protocolsMod
13
+ const templates = protocols.listAllTemplates()
14
+ const release = templates.find((template) => template.id === 'release_readiness_panel')
15
+ const builder = templates.find((template) => template.id === 'builder_review_loop')
16
+ console.log(JSON.stringify({
17
+ ids: templates.map((template) => template.id),
18
+ releaseOutputs: release?.recommendedOutputs || [],
19
+ builderKinds: builder?.defaultPhases?.map((phase) => phase.kind) || [],
20
+ }))
21
+ `, { prefix: 'swarmclaw-protocol-templates-' })
22
+
23
+ assert.ok(output.ids.includes('release_readiness_panel'))
24
+ assert.ok(output.ids.includes('research_bureau_synthesis'))
25
+ assert.ok(output.ids.includes('builder_review_loop'))
26
+ assert.ok(output.releaseOutputs.includes('go/no-go decision'))
27
+ assert.deepEqual(output.builderKinds, ['present', 'collect_independent_inputs', 'compare', 'emit_tasks', 'summarize'])
28
+ })
29
+
5
30
  test('protocol-service creates a hidden transcript run and completes a structured session', () => {
6
31
  const output = runWithTempDataDir<{
7
32
  status: string | null
@@ -77,6 +77,54 @@ export const BUILT_IN_PROTOCOL_TEMPLATES: ProtocolTemplate[] = [
77
77
  { id: 'summarize', kind: 'summarize', label: 'Summarize the outcome' },
78
78
  ],
79
79
  },
80
+ {
81
+ id: 'release_readiness_panel',
82
+ name: 'Release Readiness Panel',
83
+ description: 'Review a candidate release, compare risks, and produce a go/no-go summary.',
84
+ builtIn: true,
85
+ singleAgentAllowed: true,
86
+ tags: ['release', 'quality', 'review'],
87
+ recommendedOutputs: ['blockers', 'risk summary', 'go/no-go decision'],
88
+ defaultPhases: [
89
+ { id: 'present', kind: 'present', label: 'Set the release context' },
90
+ { id: 'collect', kind: 'collect_independent_inputs', label: 'Collect release risks' },
91
+ { id: 'compare', kind: 'compare', label: 'Compare blockers and evidence' },
92
+ { id: 'decide', kind: 'decide', label: 'Make a go/no-go recommendation' },
93
+ { id: 'summarize', kind: 'summarize', label: 'Summarize release readiness' },
94
+ ],
95
+ },
96
+ {
97
+ id: 'research_bureau_synthesis',
98
+ name: 'Research Bureau Synthesis',
99
+ description: 'Collect multiple research angles and turn them into one source-backed brief.',
100
+ builtIn: true,
101
+ singleAgentAllowed: true,
102
+ tags: ['research', 'synthesis', 'decision'],
103
+ recommendedOutputs: ['findings', 'recommendation', 'open questions'],
104
+ defaultPhases: [
105
+ { id: 'present', kind: 'present', label: 'Frame the research question' },
106
+ { id: 'collect', kind: 'collect_independent_inputs', label: 'Collect research angles' },
107
+ { id: 'compare', kind: 'compare', label: 'Compare source-backed findings' },
108
+ { id: 'decide', kind: 'decide', label: 'Recommend the current direction' },
109
+ { id: 'summarize', kind: 'summarize', label: 'Write the decision brief' },
110
+ ],
111
+ },
112
+ {
113
+ id: 'builder_review_loop',
114
+ name: 'Builder Review Loop',
115
+ description: 'Move an implementation plan through builder, reviewer, decision, and follow-up summary.',
116
+ builtIn: true,
117
+ singleAgentAllowed: true,
118
+ tags: ['builder', 'review', 'implementation'],
119
+ recommendedOutputs: ['implementation notes', 'review risks', 'follow-up tasks'],
120
+ defaultPhases: [
121
+ { id: 'present', kind: 'present', label: 'Present the work item' },
122
+ { id: 'collect', kind: 'collect_independent_inputs', label: 'Collect builder and reviewer notes' },
123
+ { id: 'compare', kind: 'compare', label: 'Compare implementation and review signals' },
124
+ { id: 'emit_tasks', kind: 'emit_tasks', label: 'Emit follow-up tasks' },
125
+ { id: 'summarize', kind: 'summarize', label: 'Summarize the delivery path' },
126
+ ],
127
+ },
80
128
  {
81
129
  id: 'decision_round',
82
130
  name: 'Decision Round',
@@ -35,6 +35,29 @@ describe('stripInternalJson', () => {
35
35
  assert.equal(stripInternalJson(input).trim(), '')
36
36
  })
37
37
 
38
+ it('removes multi-line working-state JSON with nested strings and arrays', () => {
39
+ const input = [
40
+ 'Answer first.',
41
+ '{',
42
+ ' "factsUpsert": [{ "title": "Nested", "value": "brace } inside a string" }],',
43
+ ' "questionsUpsert": []',
44
+ '}',
45
+ 'Answer second.',
46
+ ].join('\n')
47
+ const result = stripInternalJson(input)
48
+ assert.equal(result, 'Answer first. Answer second.')
49
+ })
50
+
51
+ it('preserves objects with internal-looking keys when schema validation fails', () => {
52
+ const input = 'The score object is { "quality_score": "high", "quality_reasoning": 42 }'
53
+ assert.equal(stripInternalJson(input), input)
54
+ })
55
+
56
+ it('preserves taskIntent-only user JSON without classifier fields', () => {
57
+ const input = 'Example payload: { "taskIntent": "book a flight" }'
58
+ assert.equal(stripInternalJson(input), input)
59
+ })
60
+
38
61
  it('handles multiple JSON blocks, only removing internal ones', () => {
39
62
  const input = '{ "isDeliverableTask": true } some text { "foo": "bar" }'
40
63
  const result = stripInternalJson(input)
@@ -9,27 +9,156 @@
9
9
  *
10
10
  * Importable from both client and server code.
11
11
  */
12
+ import { z } from 'zod'
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Classification JSON
15
16
  // ---------------------------------------------------------------------------
16
17
 
17
18
  const INTERNAL_JSON_KEYS = [
18
- 'isDeliverableTask', 'quality_score', 'isBroadGoal',
19
- 'hasHumanSignals', 'explicitToolRequests', 'isResearchSynthesis', 'confidence',
19
+ 'factsUpsert', 'artifactsUpsert', 'planSteps', 'decisionsAppend',
20
+ 'blockersUpsert', 'questionsUpsert', 'hypothesesUpsert', 'supersedeIds',
21
+ 'taskIntent', 'isLightweightDirectChat', 'isDeliverableTask', 'quality_score',
22
+ 'isBroadGoal', 'hasHumanSignals', 'explicitToolRequests', 'isResearchSynthesis',
23
+ 'confidence', 'isIncomplete',
20
24
  ]
21
25
 
22
26
  export const INTERNAL_KEY_RE = new RegExp(`"(?:${INTERNAL_JSON_KEYS.join('|')})"`)
23
27
 
28
+ const WorkingStatePatchLikeSchema = z.object({
29
+ factsUpsert: z.array(z.unknown()).optional(),
30
+ artifactsUpsert: z.array(z.unknown()).optional(),
31
+ planSteps: z.array(z.unknown()).optional(),
32
+ decisionsAppend: z.array(z.unknown()).optional(),
33
+ blockersUpsert: z.array(z.unknown()).optional(),
34
+ questionsUpsert: z.array(z.unknown()).optional(),
35
+ hypothesesUpsert: z.array(z.unknown()).optional(),
36
+ supersedeIds: z.array(z.unknown()).optional(),
37
+ }).passthrough()
38
+
39
+ const MessageClassificationLikeSchema = z.object({
40
+ taskIntent: z.string().optional(),
41
+ isLightweightDirectChat: z.boolean().optional(),
42
+ isDeliverableTask: z.boolean().optional(),
43
+ isBroadGoal: z.boolean().optional(),
44
+ hasHumanSignals: z.boolean().optional(),
45
+ explicitToolRequests: z.array(z.unknown()).optional(),
46
+ isResearchSynthesis: z.boolean().optional(),
47
+ confidence: z.number().optional(),
48
+ }).passthrough()
49
+
50
+ const ResponseCompletenessLikeSchema = z.object({
51
+ isIncomplete: z.boolean(),
52
+ }).passthrough()
53
+
54
+ const QualityScoreLikeSchema = z.object({
55
+ quality_score: z.number(),
56
+ quality_reasoning: z.string().optional(),
57
+ }).passthrough()
58
+
59
+ interface InternalPayloadRule {
60
+ schema: z.ZodType<unknown>
61
+ distinctiveKeys: string[]
62
+ }
63
+
64
+ const INTERNAL_PAYLOAD_RULES: InternalPayloadRule[] = [
65
+ {
66
+ schema: WorkingStatePatchLikeSchema,
67
+ distinctiveKeys: [
68
+ 'factsUpsert',
69
+ 'artifactsUpsert',
70
+ 'planSteps',
71
+ 'decisionsAppend',
72
+ 'blockersUpsert',
73
+ 'questionsUpsert',
74
+ 'hypothesesUpsert',
75
+ 'supersedeIds',
76
+ ],
77
+ },
78
+ {
79
+ schema: MessageClassificationLikeSchema,
80
+ distinctiveKeys: [
81
+ 'isLightweightDirectChat',
82
+ 'isDeliverableTask',
83
+ 'isBroadGoal',
84
+ 'hasHumanSignals',
85
+ 'explicitToolRequests',
86
+ 'isResearchSynthesis',
87
+ ],
88
+ },
89
+ {
90
+ schema: ResponseCompletenessLikeSchema,
91
+ distinctiveKeys: ['isIncomplete'],
92
+ },
93
+ {
94
+ schema: QualityScoreLikeSchema,
95
+ distinctiveKeys: ['quality_score'],
96
+ },
97
+ ]
98
+
99
+ function objectIsInternalMetadata(obj: Record<string, unknown>): boolean {
100
+ for (const { schema, distinctiveKeys } of INTERNAL_PAYLOAD_RULES) {
101
+ if (!distinctiveKeys.some((key) => key in obj)) continue
102
+ if (schema.safeParse(obj).success) return true
103
+ }
104
+ return false
105
+ }
106
+
107
+ function findBalancedJsonObjectEnd(text: string, start: number): number {
108
+ if (text.charAt(start) !== '{') return -1
109
+ let depth = 0
110
+ let inString = false
111
+ let escaped = false
112
+ for (let i = start; i < text.length; i += 1) {
113
+ const c = text.charAt(i)
114
+ if (inString) {
115
+ if (escaped) escaped = false
116
+ else if (c === '\\') escaped = true
117
+ else if (c === '"') inString = false
118
+ continue
119
+ }
120
+ if (c === '"') {
121
+ inString = true
122
+ continue
123
+ }
124
+ if (c === '{') depth += 1
125
+ else if (c === '}') {
126
+ depth -= 1
127
+ if (depth === 0) return i + 1
128
+ }
129
+ }
130
+ return -1
131
+ }
132
+
24
133
  /**
25
134
  * Remove top-level `{ ... }` blocks that contain known internal classification keys.
26
- * Handles multi-line JSON. Only strips blocks where at least one internal key is present.
135
+ * Handles nested and multi-line JSON. Only strips blocks where at least one
136
+ * distinctive internal key is present and the payload passes schema validation.
27
137
  */
28
138
  export function stripInternalJson(text: string): string {
29
- return text.replace(
30
- /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g,
31
- (match) => INTERNAL_KEY_RE.test(match) ? '' : match,
32
- )
139
+ let out = text || ''
140
+ for (let guard = 0; guard < 32; guard += 1) {
141
+ let removed = false
142
+ for (let i = 0; i < out.length; i += 1) {
143
+ if (out.charAt(i) !== '{') continue
144
+ const end = findBalancedJsonObjectEnd(out, i)
145
+ if (end <= i) continue
146
+ const candidate = out.slice(i, end)
147
+ let parsed: unknown
148
+ try {
149
+ parsed = JSON.parse(candidate)
150
+ } catch {
151
+ continue
152
+ }
153
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
154
+ if (!objectIsInternalMetadata(parsed as Record<string, unknown>)) continue
155
+ out = (out.slice(0, i).replace(/\s+$/, '') + ' ' + out.slice(end).replace(/^\s+/, '')).trim()
156
+ removed = true
157
+ break
158
+ }
159
+ if (!removed) break
160
+ }
161
+ return out
33
162
  }
34
163
 
35
164
  // ---------------------------------------------------------------------------