@swarmclawai/swarmclaw 0.9.3 → 0.9.4

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 (50) hide show
  1. package/README.md +12 -10
  2. package/bundled-skills/google-workspace/SKILL.md +2 -0
  3. package/package.json +1 -1
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
  5. package/src/app/api/clawhub/install/route.ts +2 -0
  6. package/src/app/api/skills/[id]/route.ts +4 -0
  7. package/src/app/api/skills/route.ts +4 -0
  8. package/src/components/agents/agent-sheet.tsx +5 -5
  9. package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
  10. package/src/lib/server/agents/agent-thread-session.ts +1 -1
  11. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
  12. package/src/lib/server/agents/main-agent-loop.ts +259 -0
  13. package/src/lib/server/agents/orchestrator-lg.ts +12 -8
  14. package/src/lib/server/agents/orchestrator.ts +11 -7
  15. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
  16. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
  17. package/src/lib/server/chat-execution/chat-execution.ts +74 -26
  18. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +65 -30
  19. package/src/lib/server/chat-execution/stream-agent-chat.ts +69 -25
  20. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
  21. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
  22. package/src/lib/server/connectors/contact-boundaries.ts +101 -0
  23. package/src/lib/server/connectors/manager.test.ts +504 -73
  24. package/src/lib/server/connectors/manager.ts +40 -9
  25. package/src/lib/server/connectors/session-consolidation.ts +2 -0
  26. package/src/lib/server/connectors/session-kind.ts +7 -0
  27. package/src/lib/server/connectors/session.test.ts +104 -0
  28. package/src/lib/server/connectors/session.ts +5 -2
  29. package/src/lib/server/identity-continuity.test.ts +4 -3
  30. package/src/lib/server/identity-continuity.ts +8 -4
  31. package/src/lib/server/memory/session-archive-memory.ts +2 -1
  32. package/src/lib/server/session-reset-policy.test.ts +17 -3
  33. package/src/lib/server/session-reset-policy.ts +4 -2
  34. package/src/lib/server/session-tools/connector.ts +11 -10
  35. package/src/lib/server/session-tools/crud.ts +41 -7
  36. package/src/lib/server/session-tools/index.ts +2 -0
  37. package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
  38. package/src/lib/server/session-tools/memory.ts +12 -23
  39. package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
  40. package/src/lib/server/session-tools/skill-runtime.ts +382 -0
  41. package/src/lib/server/session-tools/skills.ts +575 -0
  42. package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
  43. package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
  44. package/src/lib/server/skills/skill-discovery.ts +4 -0
  45. package/src/lib/server/skills/skills-normalize.test.ts +28 -0
  46. package/src/lib/server/skills/skills-normalize.ts +93 -1
  47. package/src/lib/server/storage.ts +1 -1
  48. package/src/lib/server/tasks/task-followups.test.ts +124 -0
  49. package/src/lib/server/tasks/task-followups.ts +88 -13
  50. package/src/types/index.ts +26 -2
package/README.md CHANGED
@@ -64,7 +64,7 @@ The OpenClaw Control Plane in SwarmClaw adds:
64
64
  - Gateway import/export JSON, clone flows, and richer external runtime fleet visibility
65
65
  - Agent and route-target preferences for steering work toward OpenClaw gateways by tags or use case (`local-dev`, `single-vps`, `private-tailnet`, `browser-heavy`, `team-control`)
66
66
 
67
- The Agent Inspector Panel lets you edit OpenClaw files (`SOUL.md`, `IDENTITY.md`, `USER.md`), tune personality/system behavior, and manage OpenClaw-compatible skills. SwarmClaw also supports importing OpenClaw `SKILL.md` files from URL.
67
+ The Agent Inspector Panel lets you edit OpenClaw files (`SOUL.md`, `IDENTITY.md`, `USER.md`), tune personality/system behavior, and manage OpenClaw-compatible skills. Skills are now discoverable by default, pinned skills stay always-on for an agent, and executable `SKILL.md` metadata can drive on-demand runtime skill selection. SwarmClaw also supports importing OpenClaw `SKILL.md` files from URL.
68
68
 
69
69
  To connect an agent to an OpenClaw gateway:
70
70
 
@@ -155,7 +155,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
155
155
  The installer resolves the latest stable release tag and installs that version by default.
156
156
  It also builds the production bundle so `npm run start` is ready immediately after install.
157
157
  No Deno install is required; local sandbox execution is Docker-first with automatic host Node fallback.
158
- To pin a version: `SWARMCLAW_VERSION=v0.9.0 curl ... | bash`
158
+ To pin a version: `SWARMCLAW_VERSION=v0.9.4 curl ... | bash`
159
159
 
160
160
  Or run locally from the repo (friendly for non-technical users):
161
161
 
@@ -248,7 +248,7 @@ Notes:
248
248
  - **Connector bridge** - Discord, Slack, Telegram, WhatsApp, Teams, Matrix, OpenClaw, and others
249
249
  - **Memory + knowledge** - hybrid search, memory graph, shared knowledge store, and auto-journaling
250
250
  - **Operational guardrails** - capability policy, cost tracking, provider health, and credential failover
251
- - **Extensibility** - plugin hooks/tools/UI extensions plus reusable skills
251
+ - **Extensibility** - plugin hooks/tools/UI extensions plus reusable skills with discovery-by-default runtime selection
252
252
 
253
253
  For the full feature matrix and per-capability details, see:
254
254
  - https://swarmclaw.ai/docs
@@ -364,6 +364,8 @@ Connector ingress now also supports optional pairing/allowlist policy:
364
364
  - `/think` command can set connector thread thinking level (`low`, `medium`, `high`)
365
365
  - Session overrides also support per-thread `/reply`, `/scope`, `/thread`, `/provider`, `/model`, `/idle`, `/maxage`, and `/reset` controls
366
366
 
367
+ Direct connector sessions are now the only source of routable connector state. Main agent threads no longer inherit outbound connector targets from mirrored history, and tool-only heartbeats stay out of visible main-thread history.
368
+
367
369
  ## Agent Tools
368
370
 
369
371
  Agents can use the following tools when enabled:
@@ -727,15 +729,15 @@ On `v*` tags, GitHub Actions will:
727
729
  2. Create a GitHub Release
728
730
  3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
729
731
 
730
- #### v0.9.0 Release Readiness Notes
732
+ #### v0.9.4 Release Readiness Notes
731
733
 
732
- Before shipping `v0.9.0`, confirm the following user-facing changes are reflected in docs:
734
+ Before shipping `v0.9.4`, confirm the following user-facing changes are reflected in docs:
733
735
 
734
- 1. Install docs make it explicit that global npm installs use `swarmclaw server`, and that package-manager installs plus the curl installer prepare the sandbox/browser runtime automatically when Docker is available.
735
- 2. Sandbox docs say local `sandbox_exec` no longer requires Deno, defaults to sandbox-enabled agent configs, and falls back to host Node when Docker is unavailable.
736
- 3. Release docs mention the OpenClaw-style sandbox/runtime refresh, heartbeat deferral improvements, and the HMR-safe live chat route fix.
737
- 4. Site and README install/version strings are updated to `v0.9.0`, including pinned install snippets, release notes index text, and sidebar/footer labels.
738
- 5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.0`) versus the git tag (`v0.9.0`).
736
+ 1. Skills docs explain that local skills are discoverable by default, while `skillIds` now mean pinned always-on skills for an agent.
737
+ 2. Runtime-skill docs mention executable skill metadata, on-demand selection, and the `use_skill` / `manage_skills` flow instead of implying every discovered skill is inlined into the prompt.
738
+ 3. Connector/heartbeat docs mention that routable connector state is kept on direct connector sessions only, sender quiet-boundary memories are enforced before reply generation, and tool-only heartbeats no longer pollute visible main-thread history.
739
+ 4. Site and README install/version strings are updated to `v0.9.4`, including pinned install snippets, release notes index text, and sidebar/footer labels.
740
+ 5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.4`) versus the git tag (`v0.9.4`).
739
741
 
740
742
  ## CLI
741
743
 
@@ -4,6 +4,8 @@ description: Use Google Workspace CLI (`gws`) for Drive, Docs, Sheets, Gmail, Ca
4
4
  homepage: https://github.com/googleworkspace/cli
5
5
  metadata:
6
6
  openclaw:
7
+ toolNames: [google_workspace, gws]
8
+ capabilities: [google-workspace, google-docs, google-drive, google-sheets, gmail, google-calendar, google-chat]
7
9
  requires:
8
10
  bins: [gws]
9
11
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -189,7 +189,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
189
189
  syntheticSession.fallbackCredentialIds = route?.fallbackCredentialIds || syntheticSession.fallbackCredentialIds || []
190
190
  syntheticSession.gatewayProfileId = route?.gatewayProfileId ?? syntheticSession.gatewayProfileId ?? null
191
191
  syntheticSession.apiEndpoint = resolvedEndpoint
192
- const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
192
+ const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent, syntheticSession.cwd)
193
193
  const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
194
194
  const fullSystemPrompt = [agentSystemPrompt, chatroomContext].filter(Boolean).join('\n\n')
195
195
  const history = buildHistoryForAgent(freshChatroom, agent.id, imagePath, attachedFiles)
@@ -45,6 +45,8 @@ export async function POST(req: Request) {
45
45
  homepage: normalized.homepage,
46
46
  primaryEnv: normalized.primaryEnv,
47
47
  skillKey: normalized.skillKey,
48
+ toolNames: normalized.toolNames,
49
+ capabilities: normalized.capabilities,
48
50
  always: normalized.always,
49
51
  installOptions: normalized.installOptions,
50
52
  skillRequirements: normalized.skillRequirements,
@@ -37,11 +37,15 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
37
37
  homepage: normalized.homepage ?? null,
38
38
  primaryEnv: normalized.primaryEnv ?? null,
39
39
  skillKey: normalized.skillKey ?? null,
40
+ toolNames: normalized.toolNames,
41
+ capabilities: normalized.capabilities,
40
42
  always: typeof normalized.always === 'boolean' ? normalized.always : false,
41
43
  installOptions: normalized.installOptions,
42
44
  skillRequirements: normalized.skillRequirements,
43
45
  detectedEnvVars: normalized.detectedEnvVars,
44
46
  security: normalized.security,
47
+ invocation: normalized.invocation,
48
+ commandDispatch: normalized.commandDispatch,
45
49
  frontmatter: normalized.frontmatter,
46
50
  scope: updatedScope,
47
51
  agentIds: updatedAgentIds,
@@ -32,11 +32,15 @@ export async function POST(req: Request) {
32
32
  homepage: normalized.homepage,
33
33
  primaryEnv: normalized.primaryEnv,
34
34
  skillKey: normalized.skillKey,
35
+ toolNames: normalized.toolNames,
36
+ capabilities: normalized.capabilities,
35
37
  always: normalized.always,
36
38
  installOptions: normalized.installOptions,
37
39
  skillRequirements: normalized.skillRequirements,
38
40
  detectedEnvVars: normalized.detectedEnvVars,
39
41
  security: normalized.security,
42
+ invocation: normalized.invocation,
43
+ commandDispatch: normalized.commandDispatch,
40
44
  frontmatter: normalized.frontmatter,
41
45
  scope,
42
46
  agentIds,
@@ -2213,7 +2213,7 @@ export function AgentSheet() {
2213
2213
  <div ref={(node) => { sectionRefs.current.tools = node }}>
2214
2214
  <SectionCard
2215
2215
  title="Tools & Delegation"
2216
- description="Enable plugins, skills, MCP tools, and delegation behavior for this agent."
2216
+ description="Enable plugins, pin preferred skills, connect MCP tools, and configure delegation behavior for this agent."
2217
2217
  >
2218
2218
  {/* Plugins — hidden for providers that manage capabilities outside LangGraph */}
2219
2219
  {!hasNativeCapabilities && (
@@ -2281,7 +2281,7 @@ export function AgentSheet() {
2281
2281
  <div className="mb-8">
2282
2282
  <div className="flex items-center justify-between mb-2">
2283
2283
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">
2284
- Skills <span className="normal-case tracking-normal font-normal text-text-3">(from ~/.claude/skills/)</span>
2284
+ Pinned Claude Skills <span className="normal-case tracking-normal font-normal text-text-3">(from ~/.claude/skills/)</span>
2285
2285
  </label>
2286
2286
  <button
2287
2287
  onClick={loadClaudeSkills}
@@ -2297,7 +2297,7 @@ export function AgentSheet() {
2297
2297
  Refresh
2298
2298
  </button>
2299
2299
  </div>
2300
- <p className="text-[12px] text-text-3/60 mb-3">When delegated to, this agent will be instructed to use these skills.</p>
2300
+ <p className="text-[12px] text-text-3/60 mb-3">Optional preference list. Pinned Claude skills are called out explicitly when this agent is delegated work.</p>
2301
2301
  {claudeSkills.length > 0 ? (
2302
2302
  <div className="flex flex-wrap gap-2">
2303
2303
  {claudeSkills.map((s) => {
@@ -2328,9 +2328,9 @@ export function AgentSheet() {
2328
2328
  {Object.keys(dynamicSkills).length > 0 && (
2329
2329
  <div className="mb-8">
2330
2330
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
2331
- Custom Skills <span className="normal-case tracking-normal font-normal text-text-3">(from Skills manager)</span>
2331
+ Pinned Skills <span className="normal-case tracking-normal font-normal text-text-3">(from Skills manager)</span>
2332
2332
  </label>
2333
- <p className="text-[12px] text-text-3/60 mb-3">Skill content is injected into the system prompt when this agent runs.</p>
2333
+ <p className="text-[12px] text-text-3/60 mb-3">All ready local skills are discoverable by default. Pin skills here only when they should stay in this agent&apos;s prompt as always-on guidance.</p>
2334
2334
  <div className="flex flex-wrap gap-2">
2335
2335
  {Object.values(dynamicSkills).map((s) => {
2336
2336
  const active = skillIds.includes(s.id)
@@ -174,4 +174,68 @@ describe('ensureAgentThreadSession', () => {
174
174
 
175
175
  assert.equal(output.session.openclawAgentId, 'main')
176
176
  })
177
+
178
+ it('clears stale connector routing state from an existing agent shortcut session', () => {
179
+ const output = runWithTempDataDir(`
180
+ const storageMod = await import('@/lib/server/storage')
181
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
182
+ const helperMod = await import('@/lib/server/agents/agent-thread-session')
183
+ const ensureAgentThreadSession = helperMod.ensureAgentThreadSession
184
+ || helperMod.default?.ensureAgentThreadSession
185
+ || helperMod['module.exports']?.ensureAgentThreadSession
186
+
187
+ const now = Date.now()
188
+ storage.saveAgents({
189
+ molly: {
190
+ id: 'molly',
191
+ name: 'Molly',
192
+ provider: 'openai',
193
+ model: 'gpt-test',
194
+ credentialId: null,
195
+ apiEndpoint: null,
196
+ fallbackCredentialIds: [],
197
+ heartbeatEnabled: true,
198
+ heartbeatIntervalSec: 600,
199
+ threadSessionId: 'agent-chat-molly-existing',
200
+ createdAt: now,
201
+ updatedAt: now,
202
+ plugins: ['memory'],
203
+ },
204
+ })
205
+ storage.saveSessions({
206
+ 'agent-chat-molly-existing': {
207
+ id: 'agent-chat-molly-existing',
208
+ name: 'Molly',
209
+ cwd: process.env.WORKSPACE_DIR,
210
+ user: 'default',
211
+ provider: 'openai',
212
+ model: 'gpt-old',
213
+ claudeSessionId: null,
214
+ codexThreadId: null,
215
+ opencodeSessionId: null,
216
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
217
+ messages: [],
218
+ createdAt: now,
219
+ lastActiveAt: now,
220
+ sessionType: 'human',
221
+ agentId: 'molly',
222
+ plugins: ['memory'],
223
+ connectorContext: {
224
+ connectorId: 'conn-1',
225
+ channelId: 'wrong-chat',
226
+ senderId: 'wrong-user',
227
+ },
228
+ },
229
+ })
230
+
231
+ const session = ensureAgentThreadSession('molly')
232
+ const persisted = storage.loadSessions()[session.id]
233
+
234
+ console.log(JSON.stringify({
235
+ connectorContext: persisted.connectorContext || null,
236
+ }))
237
+ `)
238
+
239
+ assert.equal(output.connectorContext, null)
240
+ })
177
241
  })
@@ -64,7 +64,7 @@ function buildThreadSession(agent: Agent, sessionId: string, user: string, creat
64
64
  connectorIdleTimeoutSec: existing?.connectorIdleTimeoutSec || null,
65
65
  connectorMaxAgeSec: existing?.connectorMaxAgeSec || null,
66
66
  mailbox: existing?.mailbox || null,
67
- connectorContext: existing?.connectorContext || undefined,
67
+ connectorContext: undefined,
68
68
  lastAutoMemoryAt: existing?.lastAutoMemoryAt || null,
69
69
  lastHeartbeatText: existing?.lastHeartbeatText || null,
70
70
  lastHeartbeatSentAt: existing?.lastHeartbeatSentAt || null,
@@ -195,6 +195,83 @@ describe('main-agent-loop advanced', () => {
195
195
  assert.equal(output.followupOk, null, 'no followup on terminal ack')
196
196
  })
197
197
 
198
+ it('persists and upgrades a skill blocker across recommend/install steps', () => {
199
+ const output = runWithTempDataDir(`
200
+ ${sessionSetupScript()}
201
+
202
+ mainLoop.handleMainLoopRunResult({
203
+ sessionId: 'main',
204
+ message: 'Continue the Google Workspace automation.',
205
+ internal: true,
206
+ source: 'heartbeat',
207
+ resultText: 'Blocked: missing capability for Google Workspace CLI in this environment.',
208
+ })
209
+ const state1 = mainLoop.getMainLoopStateForSession('main')
210
+
211
+ mainLoop.handleMainLoopRunResult({
212
+ sessionId: 'main',
213
+ message: 'Continue the Google Workspace automation.',
214
+ internal: true,
215
+ source: 'heartbeat',
216
+ resultText: 'Checked local skills.',
217
+ toolEvents: [{
218
+ name: 'manage_skills',
219
+ input: JSON.stringify({ action: 'recommend_for_task', task: 'Google Workspace automation' }),
220
+ output: JSON.stringify({ local: [{ name: 'google-workspace', status: 'needs_install' }] }),
221
+ }],
222
+ })
223
+ const state2 = mainLoop.getMainLoopStateForSession('main')
224
+
225
+ mainLoop.handleMainLoopRunResult({
226
+ sessionId: 'main',
227
+ message: 'Continue the Google Workspace automation.',
228
+ internal: true,
229
+ source: 'heartbeat',
230
+ resultText: 'Install approval requested.',
231
+ toolEvents: [{
232
+ name: 'manage_skills',
233
+ input: JSON.stringify({ action: 'install', name: 'google-workspace' }),
234
+ output: JSON.stringify({
235
+ requiresApproval: true,
236
+ approval: { id: 'appr-123' },
237
+ skill: { name: 'google-workspace' },
238
+ }),
239
+ }],
240
+ })
241
+ const state3 = mainLoop.getMainLoopStateForSession('main')
242
+
243
+ const heartbeatPrompt = mainLoop.buildMainLoopHeartbeatPrompt({
244
+ id: 'main',
245
+ shortcutForAgentId: 'agent-a',
246
+ agentId: 'agent-a',
247
+ heartbeatEnabled: true,
248
+ messages: [{ role: 'user', text: 'Deploy the system.', time: 1 }],
249
+ }, 'Base prompt')
250
+
251
+ console.log(JSON.stringify({
252
+ firstStatus: state1?.skillBlocker?.status ?? null,
253
+ firstSummary: state1?.skillBlocker?.summary ?? null,
254
+ secondStatus: state2?.skillBlocker?.status ?? null,
255
+ secondCandidates: state2?.skillBlocker?.candidateSkills ?? [],
256
+ secondAttempts: state2?.skillBlocker?.attempts ?? -1,
257
+ thirdStatus: state3?.skillBlocker?.status ?? null,
258
+ thirdApprovalId: state3?.skillBlocker?.approvalId ?? null,
259
+ promptHasSkillBlocker: heartbeatPrompt.includes('Active skill blocker:'),
260
+ promptHasApproval: heartbeatPrompt.includes('Pending approval: appr-123'),
261
+ }))
262
+ `)
263
+
264
+ assert.equal(output.firstStatus, 'new')
265
+ assert.match(String(output.firstSummary), /missing capability/i)
266
+ assert.equal(output.secondStatus, 'recommended')
267
+ assert.deepEqual(output.secondCandidates, ['google-workspace'])
268
+ assert.equal(output.secondAttempts, 1)
269
+ assert.equal(output.thirdStatus, 'approval_requested')
270
+ assert.equal(output.thirdApprovalId, 'appr-123')
271
+ assert.equal(output.promptHasSkillBlocker, true)
272
+ assert.equal(output.promptHasApproval, true)
273
+ })
274
+
198
275
  it('resets metadata miss count when structured metadata returns and keeps terminal acks at zero', () => {
199
276
  const meta = heartbeatMetaLine('progress', 'deploy', 'continue')
200
277
  const output = runWithTempDataDir(`
@@ -44,6 +44,15 @@ export interface MainLoopState {
44
44
  followupChainCount: number
45
45
  metaMissCount: number
46
46
  workingMemoryNotes: string[]
47
+ skillBlocker: {
48
+ summary: string
49
+ query: string | null
50
+ status: 'new' | 'searched' | 'recommended' | 'approval_requested' | 'installed'
51
+ attempts: number
52
+ candidateSkills: string[]
53
+ approvalId: string | null
54
+ updatedAt: number
55
+ } | null
47
56
  lastMemoryNoteAt: number | null
48
57
  lastPlannedAt: number | null
49
58
  lastReviewedAt: number | null
@@ -139,6 +148,7 @@ function defaultState(): MainLoopState {
139
148
  followupChainCount: 0,
140
149
  metaMissCount: 0,
141
150
  workingMemoryNotes: [],
151
+ skillBlocker: null,
142
152
  lastMemoryNoteAt: null,
143
153
  lastPlannedAt: null,
144
154
  lastReviewedAt: null,
@@ -220,6 +230,42 @@ function normalizeTimeline(value: unknown): MainLoopState['timeline'] {
220
230
  return out
221
231
  }
222
232
 
233
+ function normalizeSkillBlocker(value: unknown): MainLoopState['skillBlocker'] {
234
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
235
+ const record = value as Record<string, unknown>
236
+ const summary = cleanText(record.summary, 240)
237
+ if (!summary) return null
238
+ const status = record.status === 'new'
239
+ || record.status === 'searched'
240
+ || record.status === 'recommended'
241
+ || record.status === 'approval_requested'
242
+ || record.status === 'installed'
243
+ ? record.status
244
+ : 'new'
245
+ const query = cleanText(record.query, 240)
246
+ const candidateSkills = Array.isArray(record.candidateSkills)
247
+ ? uniqueStrings(record.candidateSkills.filter((entry): entry is string => typeof entry === 'string'), 6)
248
+ : []
249
+ const approvalId = typeof record.approvalId === 'string' && record.approvalId.trim()
250
+ ? record.approvalId.trim()
251
+ : null
252
+ const updatedAt = typeof record.updatedAt === 'number' && Number.isFinite(record.updatedAt)
253
+ ? Math.trunc(record.updatedAt)
254
+ : now()
255
+ const attempts = typeof record.attempts === 'number' && Number.isFinite(record.attempts)
256
+ ? Math.max(0, Math.min(6, Math.trunc(record.attempts)))
257
+ : 0
258
+ return {
259
+ summary,
260
+ query,
261
+ status,
262
+ attempts,
263
+ candidateSkills,
264
+ approvalId,
265
+ updatedAt,
266
+ }
267
+ }
268
+
223
269
  function parseHeartbeatMeta(text: string): { goal?: string; status?: MainLoopState['status']; summary?: string; nextAction?: string } | null {
224
270
  const match = (text || '').match(HEARTBEAT_META_RE)
225
271
  if (!match) return null
@@ -257,6 +303,7 @@ function clampState(state: MainLoopState): MainLoopState {
257
303
  state.metaMissCount = Math.max(0, Math.min(100, Math.trunc(state.metaMissCount || 0)))
258
304
  state.missionTokens = Math.max(0, Math.trunc(state.missionTokens || 0))
259
305
  state.missionCostUsd = Math.max(0, Number.isFinite(state.missionCostUsd) ? Number(state.missionCostUsd) : 0)
306
+ state.skillBlocker = normalizeSkillBlocker(state.skillBlocker)
260
307
  state.updatedAt = typeof state.updatedAt === 'number' && Number.isFinite(state.updatedAt) ? Math.trunc(state.updatedAt) : now()
261
308
  return state
262
309
  }
@@ -286,6 +333,7 @@ function normalizeState(input?: Partial<MainLoopState> | null): MainLoopState {
286
333
  if (typeof input.followupChainCount === 'number') next.followupChainCount = input.followupChainCount
287
334
  if (typeof input.metaMissCount === 'number') next.metaMissCount = input.metaMissCount
288
335
  if (Array.isArray(input.workingMemoryNotes)) next.workingMemoryNotes = [...input.workingMemoryNotes]
336
+ if (input.skillBlocker === null || typeof input.skillBlocker === 'object') next.skillBlocker = input.skillBlocker
289
337
  if (typeof input.lastMemoryNoteAt === 'number' || input.lastMemoryNoteAt === null) next.lastMemoryNoteAt = input.lastMemoryNoteAt ?? null
290
338
  if (typeof input.lastPlannedAt === 'number' || input.lastPlannedAt === null) next.lastPlannedAt = input.lastPlannedAt ?? null
291
339
  if (typeof input.lastReviewedAt === 'number' || input.lastReviewedAt === null) next.lastReviewedAt = input.lastReviewedAt ?? null
@@ -404,6 +452,198 @@ function formatGoalContract(goalContract: GoalContract | null): string {
404
452
  return lines.join('\n')
405
453
  }
406
454
 
455
+ function parseJsonRecord(value: string | undefined): Record<string, unknown> | null {
456
+ if (typeof value !== 'string' || !value.trim()) return null
457
+ try {
458
+ const parsed = JSON.parse(value)
459
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
460
+ return parsed as Record<string, unknown>
461
+ }
462
+ } catch {
463
+ // ignore non-JSON outputs
464
+ }
465
+ return null
466
+ }
467
+
468
+ function summarizeSelectedSkillRuntime(session: MainSessionLike | null): string {
469
+ const runtimeState = session?.skillRuntimeState
470
+ if (!runtimeState || typeof runtimeState !== 'object') return ''
471
+ const state = runtimeState as Record<string, unknown>
472
+ const selectedSkillName = cleanText(state.selectedSkillName, 160)
473
+ if (!selectedSkillName) return ''
474
+ const lines = [`Selected skill: ${selectedSkillName}`]
475
+ const lastAction = typeof state.lastAction === 'string' ? state.lastAction.trim() : ''
476
+ const lastRunToolName = cleanText(state.lastRunToolName, 120)
477
+ if (lastAction) lines.push(`Last skill action: ${lastAction}`)
478
+ if (lastRunToolName) lines.push(`Last dispatched tool: ${lastRunToolName}`)
479
+ return lines.join('\n')
480
+ }
481
+
482
+ function summarizeUseSkillToolEvent(toolEvents: MessageToolEvent[]): string | null {
483
+ const event = [...toolEvents].reverse().find((entry) => entry.name === 'use_skill')
484
+ if (!event?.output) return null
485
+ const output = parseJsonRecord(event.output)
486
+ if (!output) return null
487
+ const skill = output.skill && typeof output.skill === 'object'
488
+ ? output.skill as Record<string, unknown>
489
+ : null
490
+ const skillName = typeof skill?.name === 'string' && skill.name.trim()
491
+ ? skill.name.trim()
492
+ : typeof output.selectedSkillName === 'string' && output.selectedSkillName.trim()
493
+ ? output.selectedSkillName.trim()
494
+ : ''
495
+ if (!skillName) return null
496
+ if (output.executed === true) {
497
+ const toolName = typeof output.dispatchedTool === 'string' ? output.dispatchedTool.trim() : ''
498
+ return toolName ? `Skill run: ${skillName} via ${toolName}` : `Skill run: ${skillName}`
499
+ }
500
+ if (output.loaded === true) return `Loaded skill guidance: ${skillName}`
501
+ if (output.selected === true) return `Selected skill: ${skillName}`
502
+ return `Skill context: ${skillName}`
503
+ }
504
+
505
+ function firstMatchingLine(text: string, pattern: RegExp): string | null {
506
+ for (const line of (text || '').split('\n')) {
507
+ const trimmed = line.trim()
508
+ if (trimmed && pattern.test(trimmed)) return trimmed
509
+ }
510
+ return null
511
+ }
512
+
513
+ function deriveSkillBlockerFromToolEvents(params: {
514
+ toolEvents: MessageToolEvent[]
515
+ current: MainLoopState['skillBlocker']
516
+ query: string | null
517
+ }): MainLoopState['skillBlocker'] {
518
+ const event = [...params.toolEvents].reverse().find((entry) => entry.name === 'manage_skills')
519
+ if (!event) return params.current
520
+ const input = parseJsonRecord(event.input)
521
+ const output = parseJsonRecord(event.output)
522
+ const action = typeof input?.action === 'string' ? input.action.trim().toLowerCase() : ''
523
+ const nowTs = now()
524
+
525
+ const candidateNames = (() => {
526
+ const local = Array.isArray(output?.local)
527
+ ? output?.local
528
+ : Array.isArray(output)
529
+ ? output
530
+ : []
531
+ return uniqueStrings(local.flatMap((entry) => {
532
+ if (!entry || typeof entry !== 'object') return []
533
+ const record = entry as Record<string, unknown>
534
+ const nestedSkill = record.skill && typeof record.skill === 'object' ? record.skill as Record<string, unknown> : null
535
+ const name = typeof record.skillName === 'string'
536
+ ? record.skillName
537
+ : typeof record.name === 'string'
538
+ ? record.name
539
+ : typeof nestedSkill?.name === 'string'
540
+ ? nestedSkill.name
541
+ : ''
542
+ return name ? [name] : []
543
+ }), 4)
544
+ })()
545
+
546
+ const installSkillName = (() => {
547
+ if (typeof output?.skillName === 'string' && output.skillName.trim()) return output.skillName.trim()
548
+ if (output?.skill && typeof output.skill === 'object') {
549
+ const nested = output.skill as Record<string, unknown>
550
+ if (typeof nested.name === 'string' && nested.name.trim()) return nested.name.trim()
551
+ }
552
+ if (typeof input?.name === 'string' && input.name.trim()) return input.name.trim()
553
+ return candidateNames[0] || null
554
+ })()
555
+
556
+ if (action === 'install') {
557
+ if (output?.ok === true && output.installed === true) {
558
+ return normalizeSkillBlocker({
559
+ summary: installSkillName
560
+ ? `Installed skill "${installSkillName}". Use it on the next step instead of re-discovering skills.`
561
+ : 'Installed a skill for this blocker. Use it before re-running discovery.',
562
+ query: params.query,
563
+ status: 'installed',
564
+ attempts: (params.current?.attempts || 0) + 1,
565
+ candidateSkills: installSkillName ? [installSkillName] : candidateNames,
566
+ approvalId: null,
567
+ updatedAt: nowTs,
568
+ })
569
+ }
570
+ const approval = output?.approval && typeof output.approval === 'object'
571
+ ? output.approval as Record<string, unknown>
572
+ : null
573
+ const approvalId = typeof approval?.id === 'string' ? approval.id.trim() : ''
574
+ if (output?.requiresApproval === true || approvalId) {
575
+ return normalizeSkillBlocker({
576
+ summary: installSkillName
577
+ ? `Install approval is pending for skill "${installSkillName}". Wait for the approval instead of retrying discovery.`
578
+ : 'A skill install approval is pending. Wait for the approval instead of retrying discovery.',
579
+ query: params.query,
580
+ status: 'approval_requested',
581
+ attempts: (params.current?.attempts || 0) + 1,
582
+ candidateSkills: installSkillName ? [installSkillName] : candidateNames,
583
+ approvalId: approvalId || params.current?.approvalId || null,
584
+ updatedAt: nowTs,
585
+ })
586
+ }
587
+ }
588
+
589
+ if (action === 'recommend_for_task' || action === 'status' || action === 'search_available') {
590
+ return normalizeSkillBlocker({
591
+ summary: candidateNames.length > 0
592
+ ? `Skill candidates found: ${candidateNames.join(', ')}. Use one of them or request install approval once if needed.`
593
+ : 'Checked local skills for this blocker. Avoid repeating the same discovery loop without a materially different query.',
594
+ query: params.query,
595
+ status: candidateNames.length > 0 ? 'recommended' : 'searched',
596
+ attempts: (params.current?.attempts || 0) + 1,
597
+ candidateSkills: candidateNames,
598
+ approvalId: params.current?.approvalId || null,
599
+ updatedAt: nowTs,
600
+ })
601
+ }
602
+
603
+ return params.current
604
+ }
605
+
606
+ function deriveSkillBlockerFromText(params: {
607
+ text: string
608
+ current: MainLoopState['skillBlocker']
609
+ query: string | null
610
+ }): MainLoopState['skillBlocker'] {
611
+ const blockerLine = firstMatchingLine(
612
+ params.text,
613
+ /\b(missing capability|missing (?:binary|binaries|env|tool|command)|not installed|install required|requires .* cli|requires .* binary)\b/i,
614
+ )
615
+ if (!blockerLine) return params.current
616
+ return normalizeSkillBlocker({
617
+ summary: blockerLine,
618
+ query: params.query,
619
+ status: params.current?.status === 'approval_requested' ? 'approval_requested' : 'new',
620
+ attempts: params.current?.attempts || 0,
621
+ candidateSkills: params.current?.candidateSkills || [],
622
+ approvalId: params.current?.approvalId || null,
623
+ updatedAt: now(),
624
+ })
625
+ }
626
+
627
+ function summarizeSkillBlocker(blocker: MainLoopState['skillBlocker']): string {
628
+ if (!blocker) return ''
629
+ const lines = [
630
+ `Summary: ${blocker.summary}`,
631
+ blocker.query ? `Current query: ${blocker.query}` : '',
632
+ blocker.candidateSkills.length > 0 ? `Candidate skills: ${blocker.candidateSkills.join(', ')}` : '',
633
+ blocker.approvalId ? `Pending approval: ${blocker.approvalId}` : '',
634
+ blocker.status === 'new'
635
+ ? 'Next action: use manage_skills once this turn to recommend or inspect a fitting skill for the blocker.'
636
+ : blocker.status === 'searched'
637
+ ? 'Next action: do not repeat the same discovery blindly. Either adjust the query materially or proceed with the explicit blocker.'
638
+ : blocker.status === 'recommended'
639
+ ? 'Next action: use one recommended skill now, or request one explicit install approval if the best fit is not yet installed.'
640
+ : blocker.status === 'approval_requested'
641
+ ? 'Next action: wait for the pending approval instead of repeating discovery or install requests.'
642
+ : 'Next action: use the installed skill before re-running generic exploration.',
643
+ ]
644
+ return lines.filter(Boolean).join('\n')
645
+ }
646
+
407
647
  function extractWaitSignal(text: string, toolEvents: MessageToolEvent[]): boolean {
408
648
  const haystack = `${text}\n${toolEvents.map((event) => `${event.name} ${event.input || ''} ${event.output || ''}`).join('\n')}`
409
649
  return /\b(wait for|waiting for|approval|human reply|mailbox|watch job|pending approval)\b/i.test(haystack)
@@ -473,6 +713,8 @@ export function buildMainLoopHeartbeatPrompt(session: unknown, fallbackPrompt: s
473
713
  state.currentPlanStep ? `Current plan step: ${state.currentPlanStep}` : '',
474
714
  planLines ? `Plan:\n${planLines}` : '',
475
715
  state.pendingEvents.length > 0 ? `Pending external events:\n${summarizePendingEvents(state.pendingEvents)}` : '',
716
+ state.skillBlocker ? `Active skill blocker:\n${summarizeSkillBlocker(state.skillBlocker)}` : '',
717
+ summarizeSelectedSkillRuntime(candidate),
476
718
  boundedSummary ? `Latest summary:\n${boundedSummary}` : '',
477
719
  boundedFallbackPrompt ? `Base heartbeat instructions:\n${boundedFallbackPrompt}` : '',
478
720
  '',
@@ -627,7 +869,24 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
627
869
  const cleanedResult = persistedText.trim()
628
870
  const waitingForExternal = extractWaitSignal(resultText, toolEvents)
629
871
  const gotTerminalAck = /^HEARTBEAT_OK$/i.test(cleanedResult) || /^NO_MESSAGE$/i.test(cleanedResult)
872
+ const selectedSkillNote = summarizeUseSkillToolEvent(toolEvents)
873
+ if (selectedSkillNote) appendWorkingMemory(state, selectedSkillNote)
630
874
  state.metaMissCount = heartbeat || plan || review || gotTerminalAck ? 0 : state.metaMissCount + 1
875
+ const skillQuery = cleanText(state.nextAction || input.message || state.goal, 240)
876
+ let skillBlocker = deriveSkillBlockerFromToolEvents({
877
+ toolEvents,
878
+ current: state.skillBlocker,
879
+ query: skillQuery,
880
+ })
881
+ skillBlocker = deriveSkillBlockerFromText({
882
+ text: `${resultText}\n${toolEvents.map((event) => event.output || '').join('\n')}`,
883
+ current: skillBlocker,
884
+ query: skillQuery,
885
+ })
886
+ if ((gotTerminalAck && state.status !== 'blocked') || (state.status === 'ok' && !waitingForExternal && !input.error)) {
887
+ skillBlocker = null
888
+ }
889
+ state.skillBlocker = skillBlocker
631
890
 
632
891
  if (input.internal) {
633
892
  state.pendingEvents = []