@swarmclawai/swarmclaw 1.5.56 → 1.5.57

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.
Files changed (32) hide show
  1. package/README.md +14 -0
  2. package/package.json +1 -1
  3. package/src/app/api/chatrooms/refusal-policy/route.ts +43 -0
  4. package/src/app/api/config-versions/restore/route.ts +26 -0
  5. package/src/app/api/config-versions/route.ts +21 -0
  6. package/src/app/api/task-workflow-states/route.ts +69 -0
  7. package/src/app/api/usage/by-code/route.ts +32 -0
  8. package/src/app/api/workspaces/active/route.ts +19 -0
  9. package/src/app/api/workspaces/route.ts +46 -0
  10. package/src/cli/index.js +44 -0
  11. package/src/cli/spec.js +39 -0
  12. package/src/lib/server/agents/agent-budget-hook.ts +29 -0
  13. package/src/lib/server/agents/agent-service.ts +16 -0
  14. package/src/lib/server/agents/main-agent-loop.ts +8 -0
  15. package/src/lib/server/chat-execution/chat-turn-finalization.ts +2 -0
  16. package/src/lib/server/chat-execution/post-stream-finalization.ts +2 -0
  17. package/src/lib/server/chatrooms/chatroom-refusal.ts +108 -0
  18. package/src/lib/server/config-versions/config-version-repository.ts +116 -0
  19. package/src/lib/server/portability/export.ts +201 -5
  20. package/src/lib/server/portability/import.ts +214 -23
  21. package/src/lib/server/runtime/session-run-manager/enqueue.ts +8 -2
  22. package/src/lib/server/tasks/workflow-state-repository.ts +98 -0
  23. package/src/lib/server/usage/cost-attribution.ts +85 -0
  24. package/src/lib/server/usage/resolve-billing-codes.ts +22 -0
  25. package/src/lib/server/workspaces/workspace-registry.ts +143 -0
  26. package/src/types/config-version.ts +20 -0
  27. package/src/types/misc.ts +12 -0
  28. package/src/types/mission.ts +4 -0
  29. package/src/types/session.ts +2 -0
  30. package/src/types/task.ts +4 -0
  31. package/src/types/workflow-state.ts +41 -0
  32. package/src/types/workspace.ts +27 -0
package/README.md CHANGED
@@ -399,6 +399,20 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.5.57 Highlights
403
+
404
+ This release closes the org-orchestration feature gap with Paperclip while keeping SwarmClaw's autonomous-assistant focus. Most additions are additive; nothing existing has changed shape.
405
+
406
+ - **Workspace templates: full export/import bundle.** `src/lib/server/portability/{export,import}.ts` now round-trips agents, skills, schedules, **connectors** (with secret scrubbing), **chatrooms**, **MCP servers**, **projects**, **goals**, and an extension manifest reference. Manifest version bumped to `2`; v1 bundles still import. Connectors and MCP servers re-import with credentials stripped — the response payload now lists which records `needCredentials` so the UI can prompt. ID remapping handles cross-references (chatrooms → agents, schedules → agents, goals → projects/agents).
407
+ - **Per-agent budget enforcement at enqueue.** New `src/lib/server/agents/agent-budget-hook.ts` mirrors the existing mission budget hook. When an agent has `budgetAction: 'block'` and any window (`hourlyBudget`/`dailyBudget`/`monthlyBudget`) is exhausted, autonomous enqueues now fail fast in `session-run-manager` instead of getting blocked deeper in the chat-turn pipeline. User-initiated chats still flow through (so users can talk to an agent that's hit its cap). Default `'warn'` behavior is unchanged.
408
+ - **Goal hierarchy ancestry through Mission.** `Mission.goalId` is a new optional field. When a session has a `missionId` and the bound mission has a `goalId`, `main-agent-loop.ts` now walks `mission → goal → parentGoal → …` so the full Initiative/Project/Goal ancestry flows into the agent system prompt — previously only direct session-level goals were resolved.
409
+ - **Billing codes / cost attribution.** `Mission`, `BoardTask`, `Session`, and `UsageRecord` accept an optional `billingCodes: string[]`. `resolveBillingCodesForSession` combines session + mission codes when usage is appended, and the new `GET /api/usage/by-code?codes=foo,bar&range=7d` endpoint rolls up cost per code (and per agent within each code). Lets users running multiple parallel projects answer "what did Project X cost?" across agents, missions, and ad-hoc chats.
410
+ - **Customizable task workflow states.** New `WorkflowState` collection (`src/lib/server/tasks/workflow-state-repository.ts`) stores team-defined states like "Needs Review" or "Blocked on PM" that are orthogonal to `BoardTaskStatus` lifecycle. `BoardTask.workflowStateId` references one of seven defaults (Triage / Backlog / Todo / In Progress / Needs Review / Done / Cancelled) or any custom state. CRUD via `GET|POST|DELETE /api/task-workflow-states`. Atomic checkout via `task-checkout.ts` was already in place.
411
+ - **Cross-agent delegation refusal policy.** `Chatroom.onRefusal` (`'reroute' | 'escalate' | 'human'`) and `Chatroom.escalationTargetAgentId` formalize what happens when a delegated agent declines work. `chatroom-refusal.ts` reroutes to another room member, escalates to a configured target, or surfaces a `human_loop` approval. Policy management at `POST /api/chatrooms/refusal-policy`; simulation at `PUT` for tests.
412
+ - **Configuration version history.** Every `updateAgent` call now snapshots the prior agent state into `config-versions.json` (capped at 50 versions per entity). `GET /api/config-versions?entityKind=agent&entityId=...` lists history; `POST /api/config-versions/restore` rolls back. Foundation for extending to extensions, connectors, MCP servers, chatrooms, and projects.
413
+ - **Multi-workspace scaffolding.** New `Workspace` registry with `GET|POST|PATCH|DELETE /api/workspaces` and `GET|POST /api/workspaces/active`. The default workspace seeds itself on first read; switching the active workspace persists to `workspace-registry.json`. **Note:** this is metadata only in v1.5.57 — actual data-dir forking per workspace is intentionally deferred (low-risk shipping).
414
+ - **CLI manifest expanded.** New top-level groups: `workspaces`, `workflow-states`, `config-versions`, `cost-attribution`, `chatroom-policy`. Run `swarmclaw workspaces list`, `swarmclaw cost-attribution by-code --query codes=client-a,range=30d`, `swarmclaw config-versions list --query entityKind=agent,entityId=...`, etc. CLI route-coverage test passes.
415
+
402
416
  ### v1.5.56 Highlights
403
417
 
404
418
  - **Fix: TTS error responses are now proper JSON instead of a raw Buffer blob.** `POST /api/tts` and `POST /api/tts/stream` previously returned `500` with the error message wrapped in a `new NextResponse(string, ...)` that the CLI JSON-decoded into `{"type":"Buffer","data":[78,111,...]}`. Both routes now return `NextResponse.json({error}, {status: 500})`. Regression test added.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.56",
3
+ "version": "1.5.57",
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",
@@ -0,0 +1,43 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
3
+ import { setChatroomRefusalPolicy, handleAgentRefusal } from '@/lib/server/chatrooms/chatroom-refusal'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ const VALID_POLICIES = new Set(['reroute', 'escalate', 'human'])
8
+
9
+ export async function POST(req: Request) {
10
+ const { data: body, error } = await safeParseBody(req)
11
+ if (error) return error
12
+ const chatroomId = typeof body?.chatroomId === 'string' ? body.chatroomId : null
13
+ const policy = typeof body?.policy === 'string' ? body.policy : null
14
+ if (!chatroomId) return NextResponse.json({ error: 'chatroomId required' }, { status: 400 })
15
+ if (!policy || !VALID_POLICIES.has(policy)) {
16
+ return NextResponse.json({ error: 'policy must be reroute|escalate|human' }, { status: 400 })
17
+ }
18
+ const escalationTargetAgentId = typeof body?.escalationTargetAgentId === 'string'
19
+ ? body.escalationTargetAgentId
20
+ : null
21
+ const room = setChatroomRefusalPolicy(
22
+ chatroomId,
23
+ policy as 'reroute' | 'escalate' | 'human',
24
+ escalationTargetAgentId,
25
+ )
26
+ if (!room) return NextResponse.json({ error: 'chatroom not found' }, { status: 404 })
27
+ return NextResponse.json({ chatroom: room })
28
+ }
29
+
30
+ export async function PUT(req: Request) {
31
+ // Trigger a refusal handling decision (used by agent runtime + tests).
32
+ const { data: body, error } = await safeParseBody(req)
33
+ if (error) return error
34
+ const chatroomId = typeof body?.chatroomId === 'string' ? body.chatroomId : null
35
+ const refusingAgentId = typeof body?.refusingAgentId === 'string' ? body.refusingAgentId : null
36
+ const taskOrTopic = typeof body?.taskOrTopic === 'string' ? body.taskOrTopic : ''
37
+ const reason = typeof body?.reason === 'string' ? body.reason : 'unspecified'
38
+ if (!chatroomId || !refusingAgentId) {
39
+ return NextResponse.json({ error: 'chatroomId and refusingAgentId required' }, { status: 400 })
40
+ }
41
+ const decision = handleAgentRefusal({ chatroomId, refusingAgentId, taskOrTopic, reason })
42
+ return NextResponse.json({ decision })
43
+ }
@@ -0,0 +1,26 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
3
+ import { getVersion } from '@/lib/server/config-versions/config-version-repository'
4
+ import { updateAgent } from '@/lib/server/agents/agent-service'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export async function POST(req: Request) {
9
+ const { data: body, error } = await safeParseBody(req)
10
+ if (error) return error
11
+ const versionId = typeof body?.versionId === 'string' ? body.versionId : null
12
+ if (!versionId) return NextResponse.json({ error: 'versionId required' }, { status: 400 })
13
+
14
+ const version = getVersion(versionId)
15
+ if (!version) return NextResponse.json({ error: 'version not found' }, { status: 404 })
16
+
17
+ if (version.entityKind === 'agent') {
18
+ const restored = updateAgent(version.entityId, version.snapshot)
19
+ if (!restored) return NextResponse.json({ error: 'agent not found' }, { status: 404 })
20
+ return NextResponse.json({ ok: true, restored: { kind: 'agent', id: version.entityId } })
21
+ }
22
+
23
+ return NextResponse.json({
24
+ error: `Restore not yet implemented for kind=${version.entityKind}`,
25
+ }, { status: 501 })
26
+ }
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { listVersionsForEntity } from '@/lib/server/config-versions/config-version-repository'
3
+ import type { VersionedEntityKind } from '@/types/config-version'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ const VALID_KINDS = new Set<VersionedEntityKind>([
8
+ 'agent', 'extension', 'connector', 'mcp_server', 'chatroom', 'project',
9
+ ])
10
+
11
+ export async function GET(req: Request) {
12
+ const { searchParams } = new URL(req.url)
13
+ const kind = searchParams.get('entityKind') as VersionedEntityKind | null
14
+ const id = searchParams.get('entityId')
15
+ if (!kind || !VALID_KINDS.has(kind)) {
16
+ return NextResponse.json({ error: 'entityKind required (agent|extension|connector|mcp_server|chatroom|project)' }, { status: 400 })
17
+ }
18
+ if (!id) return NextResponse.json({ error: 'entityId required' }, { status: 400 })
19
+ const versions = listVersionsForEntity(kind, id)
20
+ return NextResponse.json({ versions })
21
+ }
@@ -0,0 +1,69 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { genId } from '@/lib/id'
3
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
4
+ import {
5
+ deleteWorkflowState,
6
+ listWorkflowStates,
7
+ resetWorkflowStatesToDefaults,
8
+ upsertWorkflowState,
9
+ } from '@/lib/server/tasks/workflow-state-repository'
10
+ import type { WorkflowState, WorkflowStateCategory } from '@/types/workflow-state'
11
+
12
+ export const dynamic = 'force-dynamic'
13
+
14
+ const VALID_CATEGORIES = new Set<WorkflowStateCategory>([
15
+ 'triage', 'backlog', 'unstarted', 'started', 'completed', 'cancelled',
16
+ ])
17
+
18
+ function asString(value: unknown): string | null {
19
+ return typeof value === 'string' && value.trim() ? value.trim() : null
20
+ }
21
+
22
+ export async function GET(req: Request) {
23
+ const { searchParams } = new URL(req.url)
24
+ const projectId = searchParams.get('projectId')
25
+ const states = listWorkflowStates(
26
+ projectId ? { projectId } : undefined,
27
+ )
28
+ return NextResponse.json({ states })
29
+ }
30
+
31
+ export async function POST(req: Request) {
32
+ const { data: body, error } = await safeParseBody(req)
33
+ if (error) return error
34
+ const label = asString(body?.label)
35
+ const categoryRaw = asString(body?.category) as WorkflowStateCategory | null
36
+ if (!label) return NextResponse.json({ error: 'label is required' }, { status: 400 })
37
+ if (!categoryRaw || !VALID_CATEGORIES.has(categoryRaw)) {
38
+ return NextResponse.json({ error: 'category must be one of triage|backlog|unstarted|started|completed|cancelled' }, { status: 400 })
39
+ }
40
+ const id = asString(body?.id) || genId()
41
+ const now = Date.now()
42
+ const state: WorkflowState = {
43
+ id,
44
+ label,
45
+ category: categoryRaw,
46
+ projectId: asString(body?.projectId),
47
+ color: asString(body?.color),
48
+ position: typeof body?.position === 'number' ? body.position : undefined,
49
+ autoArchiveAfterDays: typeof body?.autoArchiveAfterDays === 'number' ? body.autoArchiveAfterDays : null,
50
+ createdAt: now,
51
+ updatedAt: now,
52
+ }
53
+ const saved = upsertWorkflowState(state)
54
+ return NextResponse.json({ state: saved })
55
+ }
56
+
57
+ export async function DELETE(req: Request) {
58
+ const { searchParams } = new URL(req.url)
59
+ const id = searchParams.get('id')
60
+ if (id) {
61
+ const removed = deleteWorkflowState(id)
62
+ return NextResponse.json({ ok: removed })
63
+ }
64
+ if (searchParams.get('reset') === 'true') {
65
+ resetWorkflowStatesToDefaults()
66
+ return NextResponse.json({ ok: true, reset: true })
67
+ }
68
+ return NextResponse.json({ error: 'id or reset=true required' }, { status: 400 })
69
+ }
@@ -0,0 +1,32 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { listObservedBillingCodes, rollupCostByBillingCode } from '@/lib/server/usage/cost-attribution'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ const RANGE_MS: Record<string, number> = {
7
+ '24h': 24 * 60 * 60 * 1000,
8
+ '7d': 7 * 24 * 60 * 60 * 1000,
9
+ '30d': 30 * 24 * 60 * 60 * 1000,
10
+ 'all': Number.POSITIVE_INFINITY,
11
+ }
12
+
13
+ export async function GET(req: Request) {
14
+ const { searchParams } = new URL(req.url)
15
+ const codesParam = searchParams.get('codes')
16
+ const codes = codesParam
17
+ ? codesParam.split(',').map((s) => s.trim()).filter(Boolean)
18
+ : undefined
19
+ const rangeParam = searchParams.get('range') ?? 'all'
20
+ const rangeMs = RANGE_MS[rangeParam] ?? Number.POSITIVE_INFINITY
21
+ const sinceMs = Number.isFinite(rangeMs) ? Date.now() - rangeMs : 0
22
+
23
+ const rollups = rollupCostByBillingCode({ codes, sinceMs })
24
+ const observed = listObservedBillingCodes()
25
+
26
+ return NextResponse.json({
27
+ range: rangeParam,
28
+ codes: codes ?? null,
29
+ rollups,
30
+ observedCodes: observed,
31
+ })
32
+ }
@@ -0,0 +1,19 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
3
+ import { getActiveWorkspace, setActiveWorkspace } from '@/lib/server/workspaces/workspace-registry'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function GET() {
8
+ return NextResponse.json({ workspace: getActiveWorkspace() })
9
+ }
10
+
11
+ export async function POST(req: Request) {
12
+ const { data: body, error } = await safeParseBody(req)
13
+ if (error) return error
14
+ const id = typeof body?.id === 'string' ? body.id : null
15
+ if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
16
+ const workspace = setActiveWorkspace(id)
17
+ if (!workspace) return NextResponse.json({ error: 'workspace not found' }, { status: 404 })
18
+ return NextResponse.json({ workspace })
19
+ }
@@ -0,0 +1,46 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
3
+ import {
4
+ createWorkspace,
5
+ deleteWorkspace,
6
+ listWorkspaces,
7
+ updateWorkspace,
8
+ } from '@/lib/server/workspaces/workspace-registry'
9
+
10
+ export const dynamic = 'force-dynamic'
11
+
12
+ export async function GET() {
13
+ return NextResponse.json({ workspaces: listWorkspaces() })
14
+ }
15
+
16
+ export async function POST(req: Request) {
17
+ const { data: body, error } = await safeParseBody(req)
18
+ if (error) return error
19
+ const name = typeof body?.name === 'string' && body.name.trim() ? body.name.trim() : null
20
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 })
21
+ const workspace = createWorkspace({
22
+ name,
23
+ description: typeof body?.description === 'string' ? body.description : undefined,
24
+ color: typeof body?.color === 'string' ? body.color : undefined,
25
+ })
26
+ return NextResponse.json({ workspace })
27
+ }
28
+
29
+ export async function PATCH(req: Request) {
30
+ const { data: body, error } = await safeParseBody(req)
31
+ if (error) return error
32
+ const id = typeof body?.id === 'string' ? body.id : null
33
+ if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
34
+ const updated = updateWorkspace(id, body as Record<string, unknown>)
35
+ if (!updated) return NextResponse.json({ error: 'workspace not found' }, { status: 404 })
36
+ return NextResponse.json({ workspace: updated })
37
+ }
38
+
39
+ export async function DELETE(req: Request) {
40
+ const { searchParams } = new URL(req.url)
41
+ const id = searchParams.get('id')
42
+ if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
43
+ const removed = deleteWorkspace(id)
44
+ if (!removed) return NextResponse.json({ error: 'cannot delete default or unknown workspace' }, { status: 400 })
45
+ return NextResponse.json({ ok: true })
46
+ }
package/src/cli/index.js CHANGED
@@ -821,6 +821,50 @@ const COMMAND_GROUPS = [
821
821
  cmd('instantiate', 'POST', '/missions/templates/:id/instantiate', 'Create a mission from a template', { expectsJsonBody: true }),
822
822
  ],
823
823
  },
824
+ {
825
+ name: 'workspaces',
826
+ description: 'Manage logical workspaces (multi-workspace scaffolding)',
827
+ commands: [
828
+ cmd('list', 'GET', '/workspaces', 'List workspaces'),
829
+ cmd('create', 'POST', '/workspaces', 'Create a workspace', { expectsJsonBody: true }),
830
+ cmd('update', 'PATCH', '/workspaces', 'Update a workspace', { expectsJsonBody: true }),
831
+ cmd('delete', 'DELETE', '/workspaces', 'Delete a workspace (use --query id=...)'),
832
+ cmd('active', 'GET', '/workspaces/active', 'Get the active workspace'),
833
+ cmd('set-active', 'POST', '/workspaces/active', 'Set the active workspace', { expectsJsonBody: true }),
834
+ ],
835
+ },
836
+ {
837
+ name: 'workflow-states',
838
+ description: 'Manage customizable task workflow states',
839
+ commands: [
840
+ cmd('list', 'GET', '/task-workflow-states', 'List workflow states'),
841
+ cmd('create', 'POST', '/task-workflow-states', 'Create or update a workflow state', { expectsJsonBody: true }),
842
+ cmd('delete', 'DELETE', '/task-workflow-states', 'Delete a workflow state (use --query id=... or --query reset=true)'),
843
+ ],
844
+ },
845
+ {
846
+ name: 'config-versions',
847
+ description: 'Inspect and restore configuration version history',
848
+ commands: [
849
+ cmd('list', 'GET', '/config-versions', 'List versions for an entity (use --query entityKind=agent,entityId=...)'),
850
+ cmd('restore', 'POST', '/config-versions/restore', 'Restore an entity to a prior version', { expectsJsonBody: true }),
851
+ ],
852
+ },
853
+ {
854
+ name: 'cost-attribution',
855
+ description: 'Aggregate LLM cost by billing-code tags',
856
+ commands: [
857
+ cmd('by-code', 'GET', '/usage/by-code', 'Roll up cost by billing code (use --query codes=foo,bar,range=7d)'),
858
+ ],
859
+ },
860
+ {
861
+ name: 'chatroom-policy',
862
+ description: 'Configure chatroom delegation refusal policies',
863
+ commands: [
864
+ cmd('set', 'POST', '/chatrooms/refusal-policy', 'Set onRefusal policy for a chatroom', { expectsJsonBody: true }),
865
+ cmd('simulate', 'PUT', '/chatrooms/refusal-policy', 'Simulate a refusal-handling decision', { expectsJsonBody: true }),
866
+ ],
867
+ },
824
868
  {
825
869
  name: 'swarmfeed',
826
870
  description: 'SwarmFeed social network',
package/src/cli/spec.js CHANGED
@@ -559,6 +559,45 @@ const COMMAND_GROUPS = {
559
559
  delete: { description: 'Delete a goal', method: 'DELETE', path: '/goals/:id', params: ['id'] },
560
560
  },
561
561
  },
562
+ workspaces: {
563
+ description: 'Manage logical workspaces (multi-workspace scaffolding)',
564
+ commands: {
565
+ list: { description: 'List workspaces', method: 'GET', path: '/workspaces' },
566
+ create: { description: 'Create a workspace', method: 'POST', path: '/workspaces' },
567
+ update: { description: 'Update a workspace', method: 'PATCH', path: '/workspaces' },
568
+ delete: { description: 'Delete a workspace', method: 'DELETE', path: '/workspaces' },
569
+ active: { description: 'Get the active workspace', method: 'GET', path: '/workspaces/active' },
570
+ 'set-active': { description: 'Set the active workspace', method: 'POST', path: '/workspaces/active' },
571
+ },
572
+ },
573
+ 'workflow-states': {
574
+ description: 'Manage customizable task workflow states',
575
+ commands: {
576
+ list: { description: 'List workflow states', method: 'GET', path: '/task-workflow-states' },
577
+ create: { description: 'Create or update a workflow state', method: 'POST', path: '/task-workflow-states' },
578
+ delete: { description: 'Delete a workflow state (or pass --query reset=true to restore defaults)', method: 'DELETE', path: '/task-workflow-states' },
579
+ },
580
+ },
581
+ 'config-versions': {
582
+ description: 'Inspect and restore configuration version history',
583
+ commands: {
584
+ list: { description: 'List versions for an entity (--query entityKind=agent,entityId=...)', method: 'GET', path: '/config-versions' },
585
+ restore: { description: 'Restore an entity to a prior version', method: 'POST', path: '/config-versions/restore' },
586
+ },
587
+ },
588
+ 'cost-attribution': {
589
+ description: 'Aggregate cost by billing-code tags',
590
+ commands: {
591
+ 'by-code': { description: 'Roll up cost by billing code (--query codes=foo,bar,range=7d)', method: 'GET', path: '/usage/by-code' },
592
+ },
593
+ },
594
+ 'chatroom-policy': {
595
+ description: 'Configure chatroom delegation refusal policies',
596
+ commands: {
597
+ set: { description: 'Set onRefusal policy for a chatroom', method: 'POST', path: '/chatrooms/refusal-policy' },
598
+ simulate: { description: 'Simulate a refusal-handling decision', method: 'PUT', path: '/chatrooms/refusal-policy' },
599
+ },
600
+ },
562
601
  missions: {
563
602
  description: 'Manage autonomous missions',
564
603
  commands: {
@@ -0,0 +1,29 @@
1
+ import { getAgent } from '@/lib/server/agents/agent-repository'
2
+ import { checkAgentBudgetLimits } from '@/lib/server/cost'
3
+
4
+ export interface AgentBudgetHookResult {
5
+ allow: boolean
6
+ reason?: string
7
+ }
8
+
9
+ /**
10
+ * Pure, synchronous check suitable for the hot enqueue path. When an agent has
11
+ * `budgetAction: 'block'` set and any window is exhausted, denies the run
12
+ * before we even queue it. Mirrors `checkMissionBudgetForSession` so autonomous
13
+ * runs can fail fast instead of waiting until chat-turn-preparation.
14
+ *
15
+ * For `budgetAction: 'warn'` (default), always allows — the warn-only behavior
16
+ * is handled later in the turn pipeline so users see a status event.
17
+ */
18
+ export function checkAgentBudgetForSession(agentId: string | null | undefined): AgentBudgetHookResult {
19
+ if (!agentId) return { allow: true }
20
+ const agent = getAgent(agentId)
21
+ if (!agent) return { allow: true }
22
+ if ((agent.budgetAction || 'warn') !== 'block') return { allow: true }
23
+
24
+ const summary = checkAgentBudgetLimits(agent)
25
+ if (summary.exceeded.length === 0) return { allow: true }
26
+
27
+ const blockedMessage = summary.exceeded.map((entry) => entry.message).join(' ')
28
+ return { allow: false, reason: blockedMessage }
29
+ }
@@ -16,6 +16,7 @@ import {
16
16
  saveAgent,
17
17
  } from '@/lib/server/agents/agent-repository'
18
18
  import { logActivity } from '@/lib/server/activity/activity-log'
19
+ import { snapshotVersion } from '@/lib/server/config-versions/config-version-repository'
19
20
  import { getAgentSpendWindows } from '@/lib/server/cost'
20
21
  import { serviceFail, serviceOk } from '@/lib/server/service-result'
21
22
  import { listSessions, saveSession } from '@/lib/server/sessions/session-repository'
@@ -205,8 +206,10 @@ export function createAgent(input: {
205
206
  }
206
207
 
207
208
  export function updateAgent(agentId: string, body: Record<string, unknown>): Agent | null {
209
+ let preUpdateSnapshot: Agent | null = null
208
210
  const updated = patchAgent(agentId, (current) => {
209
211
  if (!current) return null
212
+ if (!preUpdateSnapshot) preUpdateSnapshot = current
210
213
  if (body.projectId === undefined && Array.isArray(body.projectIds) && body.projectIds.length > 0) {
211
214
  const first = body.projectIds[0]
212
215
  if (typeof first === 'string' && first.trim()) {
@@ -317,6 +320,19 @@ export function updateAgent(agentId: string, body: Record<string, unknown>): Age
317
320
  })
318
321
  if (!updated) return null
319
322
 
323
+ if (preUpdateSnapshot) {
324
+ try {
325
+ snapshotVersion({
326
+ entityKind: 'agent',
327
+ entityId: agentId,
328
+ snapshot: preUpdateSnapshot as unknown as Record<string, unknown>,
329
+ actor: 'user',
330
+ })
331
+ } catch (err) {
332
+ log.warn('agent-service', `Config version snapshot failed for agent ${agentId}: ${err instanceof Error ? err.message : err}`)
333
+ }
334
+ }
335
+
320
336
  if (updated.threadSessionId) {
321
337
  ensureAgentThreadSession(agentId)
322
338
  updateThreadShortcutSession(agentId, updated)
@@ -17,6 +17,7 @@ import { syncMainLoopToRunContext } from '@/lib/server/run-context'
17
17
  import { buildExecutionBrief, buildExecutionBriefContextBlock } from '@/lib/server/execution-brief'
18
18
  import { cleanText, cleanMultiline } from '@/lib/server/text-normalization'
19
19
  import { getGoalById, resolveEffectiveGoal } from '@/lib/server/goals/goal-service'
20
+ import { getMission } from '@/lib/server/missions/mission-repository'
20
21
 
21
22
  const LEGACY_META_LINE_RE = /\[(?:MAIN_LOOP_META|MAIN_LOOP_PLAN|MAIN_LOOP_REVIEW|AGENT_HEARTBEAT_META|AUTONOMY_TICK)\]\s*(\{[^\n]*\})?/i
22
23
  const AUTONOMY_TICK_RE = /\[AUTONOMY_TICK\]\s*(\{[^\n]*\})/i
@@ -513,6 +514,13 @@ function resolveSessionGoalRecord(session: Session | null | undefined): Record<s
513
514
  ? String(s.missionId).trim()
514
515
  : ''
515
516
  if (legacyMissionId) {
517
+ // Prefer the mission's bound goal (so Initiative/Project ancestry flows in)
518
+ // over treating the missionId itself as a goal id.
519
+ const mission = getMission(legacyMissionId)
520
+ if (mission?.goalId) {
521
+ const boundGoal = getGoalById(mission.goalId)
522
+ if (boundGoal) return boundGoal as unknown as Record<string, unknown>
523
+ }
516
524
  const missionGoal = getGoalById(legacyMissionId)
517
525
  if (missionGoal) return missionGoal as unknown as Record<string, unknown>
518
526
  }
@@ -49,6 +49,7 @@ import {
49
49
  replaceAllMessages,
50
50
  } from '@/lib/server/messages/message-repository'
51
51
  import { appendUsage } from '@/lib/server/usage/usage-repository'
52
+ import { resolveBillingCodesForSession } from '@/lib/server/usage/resolve-billing-codes'
52
53
  import { synchronizeWorkingStateForTurn } from '@/lib/server/working-state/service'
53
54
  import { notify } from '@/lib/server/ws-hub'
54
55
  import { selectKnowledgeCitations } from '@/lib/server/knowledge-sources'
@@ -223,6 +224,7 @@ export async function finalizeChatTurn(params: {
223
224
  durationMs,
224
225
  agentId: sessionForRun.agentId || null,
225
226
  projectId: sessionForRun.projectId || null,
227
+ billingCodes: resolveBillingCodesForSession(sessionForRun),
226
228
  }
227
229
  appendUsage(sessionId, usageRecord)
228
230
  emit({
@@ -14,6 +14,7 @@ import { extractSuggestions } from '@/lib/server/suggestions'
14
14
  import type { StructuredToolInterface } from '@langchain/core/tools'
15
15
  import { estimateCost, buildExtensionDefinitionCosts } from '@/lib/server/cost'
16
16
  import { appendUsage } from '@/lib/server/usage/usage-repository'
17
+ import { resolveBillingCodesForSession } from '@/lib/server/usage/resolve-billing-codes'
17
18
  import { runCapabilityHook } from '@/lib/server/native-capabilities'
18
19
  import {
19
20
  shouldForceExternalServiceSummary,
@@ -217,6 +218,7 @@ export async function finalizeStreamResult(opts: FinalizeStreamResultOpts): Prom
217
218
  durationMs: Date.now() - startTs,
218
219
  agentId: session.agentId || null,
219
220
  projectId: session.projectId || null,
221
+ billingCodes: resolveBillingCodesForSession(session),
220
222
  extensionDefinitionCosts,
221
223
  extensionInvocations: state.extensionInvocations.length > 0 ? state.extensionInvocations : undefined,
222
224
  }
@@ -0,0 +1,108 @@
1
+ import { loadChatroom, upsertChatroom } from '@/lib/server/chatrooms/chatroom-repository'
2
+ import { logActivity } from '@/lib/server/activity/activity-log'
3
+ import { requestApproval } from '@/lib/server/approvals'
4
+ import { log } from '@/lib/server/logger'
5
+ import type { Chatroom } from '@/types'
6
+
7
+ const TAG = 'chatroom-refusal'
8
+
9
+ export interface RefusalDecision {
10
+ action: 'reroute' | 'escalate' | 'human' | 'noop'
11
+ /** Agent id to retry the work with, when applicable. */
12
+ nextAgentId?: string | null
13
+ /** Approval id when surfaced to a human. */
14
+ approvalId?: string | null
15
+ reason?: string
16
+ }
17
+
18
+ /**
19
+ * Apply the chatroom's `onRefusal` policy when an assigned agent declines or
20
+ * returns a refusal signal for a delegated work item. Records the decision in
21
+ * the activity log and returns the next action for the caller.
22
+ */
23
+ export function handleAgentRefusal(input: {
24
+ chatroomId: string
25
+ refusingAgentId: string
26
+ taskOrTopic: string
27
+ reason: string
28
+ }): RefusalDecision {
29
+ const room = loadChatroom(input.chatroomId)
30
+ if (!room) {
31
+ log.warn(TAG, `Refusal received for unknown chatroom ${input.chatroomId}`)
32
+ return { action: 'noop' }
33
+ }
34
+
35
+ const policy = room.onRefusal ?? 'reroute'
36
+
37
+ if (policy === 'human') {
38
+ const approval = requestApproval({
39
+ category: 'human_loop',
40
+ title: `Chatroom "${room.name}" refusal needs human input`,
41
+ description: `Agent ${input.refusingAgentId} refused work: "${input.taskOrTopic}". Reason: ${input.reason}`,
42
+ data: {
43
+ chatroomId: room.id,
44
+ refusingAgentId: input.refusingAgentId,
45
+ taskOrTopic: input.taskOrTopic,
46
+ reason: input.reason,
47
+ },
48
+ })
49
+ logActivity({
50
+ entityType: 'chatroom',
51
+ entityId: room.id,
52
+ action: 'refusal_escalated_human',
53
+ actor: 'system',
54
+ summary: `Refusal from ${input.refusingAgentId} surfaced as approval ${approval.id}`,
55
+ })
56
+ return { action: 'human', approvalId: approval.id, reason: input.reason }
57
+ }
58
+
59
+ if (policy === 'escalate') {
60
+ const target = room.escalationTargetAgentId
61
+ if (target && target !== input.refusingAgentId && room.agentIds.includes(target)) {
62
+ logActivity({
63
+ entityType: 'chatroom',
64
+ entityId: room.id,
65
+ action: 'refusal_escalated',
66
+ actor: 'system',
67
+ summary: `Refusal from ${input.refusingAgentId} escalated to ${target}`,
68
+ })
69
+ return { action: 'escalate', nextAgentId: target, reason: input.reason }
70
+ }
71
+ // Fall through to reroute when no escalation target available.
72
+ }
73
+
74
+ // reroute (or escalate fallback): pick any other room member.
75
+ const candidate = room.agentIds.find((id) => id !== input.refusingAgentId)
76
+ if (candidate) {
77
+ logActivity({
78
+ entityType: 'chatroom',
79
+ entityId: room.id,
80
+ action: 'refusal_rerouted',
81
+ actor: 'system',
82
+ summary: `Refusal from ${input.refusingAgentId} rerouted to ${candidate}`,
83
+ })
84
+ return { action: 'reroute', nextAgentId: candidate, reason: input.reason }
85
+ }
86
+
87
+ return { action: 'noop', reason: 'no alternative agent available' }
88
+ }
89
+
90
+ /**
91
+ * Sets or updates a chatroom's onRefusal policy and persists the change.
92
+ */
93
+ export function setChatroomRefusalPolicy(
94
+ chatroomId: string,
95
+ policy: NonNullable<Chatroom['onRefusal']>,
96
+ escalationTargetAgentId?: string | null,
97
+ ): Chatroom | null {
98
+ const room = loadChatroom(chatroomId)
99
+ if (!room) return null
100
+ const next: Chatroom = {
101
+ ...room,
102
+ onRefusal: policy,
103
+ escalationTargetAgentId: escalationTargetAgentId ?? room.escalationTargetAgentId ?? null,
104
+ updatedAt: Date.now(),
105
+ }
106
+ upsertChatroom(chatroomId, next)
107
+ return next
108
+ }