@swarmclawai/swarmclaw 0.7.7 → 0.7.8

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 (63) hide show
  1. package/README.md +10 -9
  2. package/package.json +1 -1
  3. package/src/app/api/chats/route.ts +1 -0
  4. package/src/app/api/connectors/[id]/route.ts +20 -2
  5. package/src/app/api/connectors/route.ts +12 -8
  6. package/src/app/api/projects/[id]/route.ts +6 -2
  7. package/src/app/api/projects/route.ts +4 -3
  8. package/src/app/api/secrets/[id]/route.ts +1 -0
  9. package/src/app/api/secrets/route.ts +2 -1
  10. package/src/app/api/settings/route.ts +2 -0
  11. package/src/components/agents/agent-sheet.tsx +184 -14
  12. package/src/components/chat/chat-area.tsx +36 -19
  13. package/src/components/chat/chat-header.tsx +4 -0
  14. package/src/components/chat/delegation-banner.test.ts +14 -1
  15. package/src/components/chat/delegation-banner.tsx +1 -1
  16. package/src/components/layout/app-layout.tsx +40 -23
  17. package/src/components/projects/project-detail.tsx +217 -0
  18. package/src/components/projects/project-sheet.tsx +176 -4
  19. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  20. package/src/components/shared/settings/section-voice.tsx +11 -3
  21. package/src/components/tasks/approvals-panel.tsx +177 -18
  22. package/src/components/tasks/task-board.tsx +137 -23
  23. package/src/components/tasks/task-card.tsx +29 -0
  24. package/src/components/tasks/task-sheet.tsx +16 -4
  25. package/src/lib/server/capability-router.test.ts +22 -0
  26. package/src/lib/server/capability-router.ts +54 -18
  27. package/src/lib/server/chat-execution.ts +25 -1
  28. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  29. package/src/lib/server/connectors/manager.ts +99 -74
  30. package/src/lib/server/daemon-state.ts +83 -46
  31. package/src/lib/server/elevenlabs.test.ts +59 -1
  32. package/src/lib/server/heartbeat-service.ts +5 -1
  33. package/src/lib/server/main-agent-loop.test.ts +260 -0
  34. package/src/lib/server/main-agent-loop.ts +559 -14
  35. package/src/lib/server/orchestrator-lg.ts +1 -0
  36. package/src/lib/server/orchestrator.ts +2 -0
  37. package/src/lib/server/plugins.ts +6 -1
  38. package/src/lib/server/project-context.ts +162 -0
  39. package/src/lib/server/project-utils.ts +150 -0
  40. package/src/lib/server/queue-followups.test.ts +147 -2
  41. package/src/lib/server/queue.ts +234 -7
  42. package/src/lib/server/session-run-manager.ts +31 -0
  43. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  44. package/src/lib/server/session-tools/connector.ts +26 -1
  45. package/src/lib/server/session-tools/context.ts +5 -0
  46. package/src/lib/server/session-tools/crud.ts +265 -76
  47. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  48. package/src/lib/server/session-tools/delegate.ts +38 -2
  49. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  50. package/src/lib/server/session-tools/memory.ts +14 -2
  51. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  52. package/src/lib/server/session-tools/platform.ts +60 -19
  53. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  54. package/src/lib/server/session-tools/web.ts +153 -6
  55. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  56. package/src/lib/server/stream-agent-chat.ts +104 -30
  57. package/src/lib/server/tool-aliases.ts +2 -0
  58. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  59. package/src/lib/server/tool-capability-policy.ts +29 -1
  60. package/src/lib/server/tool-planning.test.ts +44 -0
  61. package/src/lib/server/tool-planning.ts +269 -0
  62. package/src/lib/tool-definitions.ts +2 -1
  63. package/src/types/index.ts +39 -0
@@ -136,10 +136,12 @@ export function ChatArea() {
136
136
  if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
137
137
  setMessagesLoading(false)
138
138
  })
139
- // If server reports session is still active, show streaming state
140
- if (session?.active) {
139
+
140
+ const sessionAtLoad = useAppStore.getState().sessions[requestedSessionId]
141
+ if (sessionAtLoad?.active) {
141
142
  useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
142
143
  }
144
+
143
145
  // Refresh active state from server so returning to a session restores typing indicator.
144
146
  loadSessions().then(() => {
145
147
  if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
@@ -148,6 +150,7 @@ export function ChatArea() {
148
150
  useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
149
151
  }
150
152
  }).catch((err) => console.error('Failed to refresh messages:', err))
153
+
151
154
  devServer(requestedSessionId, 'status').then((r) => {
152
155
  if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
153
156
  setDevServer(r.running ? r : null)
@@ -155,23 +158,31 @@ export function ChatArea() {
155
158
  if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
156
159
  setDevServer(null)
157
160
  })
158
- // Check browser status
159
- if (sessionHasBrowserPlugin) {
160
- checkBrowser(requestedSessionId).then((r) => {
161
- if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
162
- setBrowserActive(r.active)
163
- }).catch((err) => {
164
- if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
165
- console.error('Browser check failed:', err)
166
- setBrowserActive(false)
167
- })
168
- } else {
161
+
162
+ return () => {
163
+ cancelled = true
164
+ }
165
+ }, [loadSessions, sessionId, setDevServer, setMessages])
166
+
167
+ useEffect(() => {
168
+ if (!sessionId) return
169
+ let cancelled = false
170
+ if (!sessionHasBrowserPlugin) {
169
171
  setBrowserActive(false)
172
+ return
170
173
  }
174
+ checkBrowser(sessionId).then((r) => {
175
+ if (cancelled || useAppStore.getState().currentSessionId !== sessionId) return
176
+ setBrowserActive(r.active)
177
+ }).catch((err) => {
178
+ if (cancelled || useAppStore.getState().currentSessionId !== sessionId) return
179
+ console.error('Browser check failed:', err)
180
+ setBrowserActive(false)
181
+ })
171
182
  return () => {
172
183
  cancelled = true
173
184
  }
174
- }, [loadSessions, session?.active, sessionHasBrowserPlugin, sessionId, setDevServer, setMessages])
185
+ }, [sessionHasBrowserPlugin, sessionId])
175
186
 
176
187
  // Auto-poll messages for sessions that are actively running on the server
177
188
  const isServerActive = session?.active === true
@@ -216,10 +227,16 @@ export function ChatArea() {
216
227
  shouldPollMessages ? 2000 : undefined,
217
228
  )
218
229
 
219
- // When server-active flag drops, stop the streaming indicator
230
+ // Keep the local typing indicator aligned with the server's active state
220
231
  useEffect(() => {
221
232
  if (!sessionId) return
222
233
  const state = useChatStore.getState()
234
+ if (isServerActive) {
235
+ if (!state.streaming && !state.streamText) {
236
+ useChatStore.setState({ streaming: true, streamingSessionId: sessionId, streamText: '' })
237
+ }
238
+ return
239
+ }
223
240
  if (
224
241
  !isServerActive
225
242
  && state.streaming
@@ -230,7 +247,7 @@ export function ChatArea() {
230
247
  fetchMessages(sessionId).then(setMessages).catch(() => {})
231
248
  useChatStore.setState({ streaming: false, streamingSessionId: null, streamText: '' })
232
249
  }
233
- }, [isServerActive, sessionId])
250
+ }, [isServerActive, sessionId, setMessages])
234
251
 
235
252
  // Poll browser status while session has browser tools
236
253
  const hasBrowserTool = session?.plugins?.includes('browser')
@@ -255,7 +272,7 @@ export function ChatArea() {
255
272
  if (!sessionId) return
256
273
  await devServer(sessionId, 'stop')
257
274
  setDevServer(null)
258
- }, [sessionId])
275
+ }, [sessionId, setDevServer])
259
276
 
260
277
  const handleClear = useCallback(async () => {
261
278
  setConfirmClear(false)
@@ -263,7 +280,7 @@ export function ChatArea() {
263
280
  await clearMessages(sessionId)
264
281
  setMessages([])
265
282
  loadSessions()
266
- }, [sessionId])
283
+ }, [loadSessions, sessionId, setMessages])
267
284
 
268
285
  const handleDelete = useCallback(async () => {
269
286
  setConfirmDelete(false)
@@ -271,7 +288,7 @@ export function ChatArea() {
271
288
  await deleteChat(sessionId)
272
289
  removeSessionFromStore(sessionId)
273
290
  setCurrentSession(null)
274
- }, [sessionId])
291
+ }, [removeSessionFromStore, sessionId, setCurrentSession])
275
292
 
276
293
  const handlePrompt = useCallback((text: string) => {
277
294
  sendMessage(text)
@@ -280,12 +280,16 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
280
280
  const fromDelegateOpenCode = session.delegateResumeIds?.opencode
281
281
  ? { label: 'OpenCode', id: session.delegateResumeIds.opencode, command: `opencode run \"<task>\" --session ${session.delegateResumeIds.opencode}` }
282
282
  : null
283
+ const fromDelegateGemini = session.delegateResumeIds?.gemini
284
+ ? { label: 'Gemini', id: session.delegateResumeIds.gemini, command: `gemini --resume ${session.delegateResumeIds.gemini} --prompt \"<task>\"` }
285
+ : null
283
286
  return fromSessionClaude
284
287
  || fromSessionCodex
285
288
  || fromSessionOpenCode
286
289
  || fromDelegateClaude
287
290
  || fromDelegateCodex
288
291
  || fromDelegateOpenCode
292
+ || fromDelegateGemini
289
293
  || null
290
294
  }, [session.claudeSessionId, session.codexThreadId, session.opencodeSessionId, session.delegateResumeIds])
291
295
 
@@ -23,5 +23,18 @@ describe('parseTaskCompletion', () => {
23
23
  assert.equal(parsed?.reportPath, 'data/task-reports/abc12345.md')
24
24
  assert.equal(parsed?.workingDir, '/tmp/work')
25
25
  })
26
- })
27
26
 
27
+ it('captures Gemini resume lines from task completion payloads', () => {
28
+ const text = [
29
+ 'Task completed: **[Ship follow-up](#task:task-gemini)**',
30
+ '',
31
+ 'Gemini session: `gemini-session-7`',
32
+ '',
33
+ 'All done.',
34
+ ].join('\n')
35
+ const parsed = parseTaskCompletion(text)
36
+
37
+ assert.ok(parsed)
38
+ assert.equal(parsed?.resumeInfo, 'Gemini session: `gemini-session-7`')
39
+ })
40
+ })
@@ -169,7 +169,7 @@ export function parseTaskCompletion(text: string): TaskCompletionInfo | null {
169
169
  }
170
170
  } else if (section.startsWith('Task report: ')) {
171
171
  reportPath = section.replace('Task report: ', '').replace(/^`|`$/g, '')
172
- } else if (/^(Claude session|Codex thread|OpenCode session|CLI session):/.test(section)) {
172
+ } else if (/^(Claude session|Codex thread|OpenCode session|Gemini session|CLI session):/.test(section)) {
173
173
  resumeInfo = section
174
174
  } else if (section.trim()) {
175
175
  resultParts.push(section)
@@ -141,6 +141,7 @@ export function AppLayout() {
141
141
  const execApprovals = useApprovalStore((s) => s.approvals)
142
142
  const loadExecApprovals = useApprovalStore((s) => s.loadApprovals)
143
143
  const pruneExecApprovals = useApprovalStore((s) => s.pruneExpired)
144
+ const appSettings = useAppStore((s) => s.appSettings)
144
145
  const isDesktop = useMediaQuery('(min-width: 768px)')
145
146
  const hasSelectedSession = !!(currentSessionId && sessions[currentSessionId])
146
147
 
@@ -168,11 +169,23 @@ export function AppLayout() {
168
169
  pruneExecApprovals()
169
170
  }, 10000)
170
171
 
171
- const appSettings = useAppStore((s) => s.appSettings)
172
172
  const [agentViewMode, setAgentViewMode] = useState<'chat' | 'config'>('chat')
173
173
  const [profileSheetOpen, setProfileSheetOpen] = useState(false)
174
174
  const [canvasDismissedFor, setCanvasDismissedFor] = useState<string | null>(null)
175
175
 
176
+ const isViewEnabled = useCallback((view: AppView) => {
177
+ if (view === 'projects') return appSettings.projectManagementEnabled !== false
178
+ if (view === 'tasks') return appSettings.taskManagementEnabled !== false
179
+ if (view === 'chatrooms') return plugins['chatroom']?.enabled !== false
180
+ if (view === 'schedules') return plugins['schedule']?.enabled !== false
181
+ if (view === 'memory') return plugins['memory']?.enabled !== false
182
+ if (view === 'connectors') return plugins['connectors']?.enabled !== false
183
+ if (view === 'webhooks') return plugins['http']?.enabled !== false
184
+ if (view === 'wallets') return plugins['wallet']?.enabled !== false
185
+ if (view === 'logs') return plugins['monitor']?.enabled !== false
186
+ return true
187
+ }, [appSettings.projectManagementEnabled, appSettings.taskManagementEnabled, plugins])
188
+
176
189
  const handleShortcutKey = useCallback((e: KeyboardEvent) => {
177
190
  const mod = e.metaKey || e.ctrlKey
178
191
  // Cmd+N / Ctrl+N — jump to the default agent shortcut
@@ -188,8 +201,10 @@ export function AppLayout() {
188
201
  }
189
202
  // Cmd+Shift+T / Ctrl+Shift+T — jump to tasks
190
203
  if (mod && e.shiftKey && e.key.toLowerCase() === 't') {
204
+ const state = useAppStore.getState()
205
+ if (state.appSettings.taskManagementEnabled === false) return
191
206
  e.preventDefault()
192
- useAppStore.getState().setActiveView('tasks')
207
+ state.setActiveView('tasks')
193
208
  }
194
209
  }, [])
195
210
 
@@ -222,6 +237,13 @@ export function AppLayout() {
222
237
  }
223
238
  }, [appSettings.themeHue])
224
239
 
240
+ useEffect(() => {
241
+ if (!isViewEnabled(activeView)) {
242
+ setActiveView('home')
243
+ setSidebarOpen(false)
244
+ }
245
+ }, [activeView, isViewEnabled, setActiveView, setSidebarOpen])
246
+
225
247
  const [pluginSidebarItems, setPluginSidebarItems] = useState<Array<{ id: string; label: string; href: string }>>([])
226
248
 
227
249
  const refreshPluginState = useCallback(() => {
@@ -235,17 +257,6 @@ export function AppLayout() {
235
257
 
236
258
  useWs('plugins', refreshPluginState)
237
259
 
238
- const isViewEnabled = useCallback((view: AppView) => {
239
- if (view === 'chatrooms') return plugins['chatroom']?.enabled !== false
240
- if (view === 'schedules') return plugins['schedule']?.enabled !== false
241
- if (view === 'memory') return plugins['memory']?.enabled !== false
242
- if (view === 'connectors') return plugins['connectors']?.enabled !== false
243
- if (view === 'webhooks') return plugins['http']?.enabled !== false
244
- if (view === 'wallets') return plugins['wallet']?.enabled !== false
245
- if (view === 'logs') return plugins['monitor']?.enabled !== false
246
- return true
247
- }, [plugins])
248
-
249
260
  const [railExpanded, setRailExpanded] = useState(() => {
250
261
  const stored = safeStorageGet(RAIL_EXPANDED_KEY)
251
262
  return stored === null ? true : stored === 'true'
@@ -262,6 +273,7 @@ export function AppLayout() {
262
273
  }
263
274
 
264
275
  const openNewSheet = () => {
276
+ if (!isViewEnabled(activeView)) return
265
277
  if (activeView === 'agents') setAgentSheetOpen(true)
266
278
  else if (activeView === 'schedules') setScheduleSheetOpen(true)
267
279
  else if (activeView === 'tasks') setTaskSheetOpen(true)
@@ -278,6 +290,7 @@ export function AppLayout() {
278
290
  }
279
291
 
280
292
  const handleNavClick = (view: AppView) => {
293
+ if (!isViewEnabled(view)) return
281
294
  if (FULL_WIDTH_VIEWS.has(view)) {
282
295
  setActiveView(view)
283
296
  setSidebarOpen(false)
@@ -491,11 +504,13 @@ export function AppLayout() {
491
504
  </svg>
492
505
  </NavItem>
493
506
  )}
494
- <NavItem view="projects" label="Projects" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('projects')}>
495
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
496
- <path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7-7H4a2 2 0 0 0-2 2v17Z" /><path d="M14 2v7h7" />
497
- </svg>
498
- </NavItem>
507
+ {isViewEnabled('projects') && (
508
+ <NavItem view="projects" label="Projects" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('projects')}>
509
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
510
+ <path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7-7H4a2 2 0 0 0-2 2v17Z" /><path d="M14 2v7h7" />
511
+ </svg>
512
+ </NavItem>
513
+ )}
499
514
  </div>
500
515
 
501
516
  <div className={`flex flex-col gap-0.5 ${railExpanded ? '' : 'items-center'}`}>
@@ -504,11 +519,13 @@ export function AppLayout() {
504
519
  ) : (
505
520
  <div className="my-1 h-px w-6 bg-white/[0.06]" />
506
521
  )}
507
- <NavItem view="tasks" label="Tasks" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('tasks')} badge={pendingApprovalCount}>
508
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
509
- <path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" /><rect x="9" y="3" width="6" height="4" rx="1" /><path d="M9 14l2 2 4-4" />
510
- </svg>
511
- </NavItem>
522
+ {isViewEnabled('tasks') && (
523
+ <NavItem view="tasks" label="Tasks" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('tasks')} badge={pendingApprovalCount}>
524
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
525
+ <path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" /><rect x="9" y="3" width="6" height="4" rx="1" /><path d="M9 14l2 2 4-4" />
526
+ </svg>
527
+ </NavItem>
528
+ )}
512
529
  <NavItem view="approvals" label="Approvals" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('approvals')} badge={pendingApprovalCount}>
513
530
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
514
531
  <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/>
@@ -16,6 +16,13 @@ function relativeDate(ts: number): string {
16
16
  return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
17
17
  }
18
18
 
19
+ function formatHeartbeatInterval(intervalSec?: number | null): string {
20
+ if (!intervalSec || intervalSec <= 0) return 'Manual'
21
+ if (intervalSec % 3600 === 0) return `${intervalSec / 3600}h`
22
+ if (intervalSec % 60 === 0) return `${intervalSec / 60}m`
23
+ return `${intervalSec}s`
24
+ }
25
+
19
26
  const STATUS_STYLES: Record<string, string> = {
20
27
  backlog: 'bg-white/[0.06] text-text-3',
21
28
  queued: 'bg-amber-500/15 text-amber-400',
@@ -92,6 +99,8 @@ export function ProjectDetail() {
92
99
  const tasks = useAppStore((s) => s.tasks) as Record<string, BoardTask>
93
100
  const schedules = useAppStore((s) => s.schedules) as Record<string, Schedule>
94
101
  const loadAgents = useAppStore((s) => s.loadAgents)
102
+ const secrets = useAppStore((s) => s.secrets)
103
+ const loadSecrets = useAppStore((s) => s.loadSecrets)
95
104
  const setEditingProjectId = useAppStore((s) => s.setEditingProjectId)
96
105
  const setProjectSheetOpen = useAppStore((s) => s.setProjectSheetOpen)
97
106
  const setActiveView = useAppStore((s) => s.setActiveView)
@@ -100,6 +109,8 @@ export function ProjectDetail() {
100
109
  const setTaskSheetOpen = useAppStore((s) => s.setTaskSheetOpen)
101
110
  const setEditingScheduleId = useAppStore((s) => s.setEditingScheduleId)
102
111
  const setScheduleSheetOpen = useAppStore((s) => s.setScheduleSheetOpen)
112
+ const setEditingSecretId = useAppStore((s) => s.setEditingSecretId)
113
+ const setSecretSheetOpen = useAppStore((s) => s.setSecretSheetOpen)
103
114
 
104
115
  const [assignPickerOpen, setAssignPickerOpen] = useState(false)
105
116
  const [now, setNow] = useState(() => Date.now())
@@ -109,6 +120,11 @@ export function ProjectDetail() {
109
120
  return () => window.clearInterval(intervalId)
110
121
  }, [])
111
122
 
123
+ useEffect(() => {
124
+ if (!activeProjectFilter) return
125
+ void loadSecrets()
126
+ }, [activeProjectFilter, loadSecrets])
127
+
112
128
  const project = activeProjectFilter ? projects[activeProjectFilter] : null
113
129
 
114
130
  const projectAgents = useMemo(
@@ -128,6 +144,11 @@ export function ProjectDetail() {
128
144
  [schedules, activeProjectFilter],
129
145
  )
130
146
 
147
+ const projectSecrets = useMemo(
148
+ () => Object.values(secrets).filter((secret) => secret.projectId === activeProjectFilter),
149
+ [secrets, activeProjectFilter],
150
+ )
151
+
131
152
  const completedTasks = projectTasks.filter((t) => t.status === 'completed').length
132
153
  const totalTasks = projectTasks.length
133
154
  const progressPct = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
@@ -188,6 +209,23 @@ export function ProjectDetail() {
188
209
  [now, projectSchedules],
189
210
  )
190
211
 
212
+ const capabilityHints = Array.isArray(project?.capabilityHints) ? project.capabilityHints : []
213
+ const priorities = Array.isArray(project?.priorities) ? project.priorities : []
214
+ const openObjectives = Array.isArray(project?.openObjectives) ? project.openObjectives : []
215
+ const credentialRequirements = Array.isArray(project?.credentialRequirements) ? project.credentialRequirements : []
216
+ const successMetrics = Array.isArray(project?.successMetrics) ? project.successMetrics : []
217
+ const hasOperatingContext = Boolean(
218
+ project?.objective
219
+ || project?.audience
220
+ || priorities.length
221
+ || openObjectives.length
222
+ || capabilityHints.length
223
+ || credentialRequirements.length
224
+ || successMetrics.length
225
+ || project?.heartbeatPrompt
226
+ || project?.heartbeatIntervalSec,
227
+ )
228
+
191
229
  const busiestAgent = useMemo(() => {
192
230
  return projectAgents
193
231
  .map((agent) => ({
@@ -305,6 +343,185 @@ export function ProjectDetail() {
305
343
  </div>
306
344
  </div>
307
345
 
346
+ <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_280px] gap-4 mb-8">
347
+ <div className="rounded-[16px] border border-white/[0.06] bg-white/[0.02] px-5 py-5">
348
+ <div className="flex items-center justify-between gap-4 mb-4">
349
+ <div>
350
+ <h2 className="font-display text-[18px] font-700 tracking-[-0.02em] text-text">Project Operating System</h2>
351
+ <p className="text-[12px] text-text-3/60 mt-1">Define what this project is trying to achieve, how agents should operate, and what long-lived context matters.</p>
352
+ </div>
353
+ <button
354
+ onClick={() => { setEditingProjectId(project.id); setProjectSheetOpen(true) }}
355
+ className="shrink-0 px-3 py-2 rounded-[10px] bg-white/[0.04] text-[12px] font-600 text-text-2 hover:bg-white/[0.08] transition-all cursor-pointer border-none"
356
+ style={{ fontFamily: 'inherit' }}
357
+ >
358
+ Configure
359
+ </button>
360
+ </div>
361
+
362
+ {hasOperatingContext ? (
363
+ <div className="grid gap-4 sm:grid-cols-2">
364
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface/60 px-4 py-3.5">
365
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/50 mb-2">Mission</div>
366
+ <div className="space-y-3">
367
+ <div>
368
+ <div className="text-[11px] font-600 text-text-3/50 mb-1">Objective</div>
369
+ <p className="text-[13px] text-text leading-relaxed">{project.objective || 'Add a durable objective for this project.'}</p>
370
+ </div>
371
+ <div>
372
+ <div className="text-[11px] font-600 text-text-3/50 mb-1">Audience</div>
373
+ <p className="text-[13px] text-text-2/80 leading-relaxed">{project.audience || 'Set who this project is for so agents can answer from that context.'}</p>
374
+ </div>
375
+ </div>
376
+ </div>
377
+
378
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface/60 px-4 py-3.5">
379
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/50 mb-2">Execution Focus</div>
380
+ <div className="space-y-3">
381
+ <div>
382
+ <div className="text-[11px] font-600 text-text-3/50 mb-1">Pilot priorities</div>
383
+ {priorities.length > 0 ? (
384
+ <div className="flex flex-wrap gap-1.5">
385
+ {priorities.map((priority) => (
386
+ <span key={priority} className="rounded-full bg-accent-soft px-2.5 py-1 text-[11px] font-600 text-accent-bright">
387
+ {priority}
388
+ </span>
389
+ ))}
390
+ </div>
391
+ ) : (
392
+ <p className="text-[12px] text-text-3/50">No priorities captured yet.</p>
393
+ )}
394
+ </div>
395
+ <div>
396
+ <div className="text-[11px] font-600 text-text-3/50 mb-1">Open objectives</div>
397
+ {openObjectives.length > 0 ? (
398
+ <div className="space-y-1.5">
399
+ {openObjectives.map((objective) => (
400
+ <div key={objective} className="flex items-start gap-2 text-[12px] text-text-2">
401
+ <span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-400" />
402
+ <span>{objective}</span>
403
+ </div>
404
+ ))}
405
+ </div>
406
+ ) : (
407
+ <p className="text-[12px] text-text-3/50">No open objectives captured yet.</p>
408
+ )}
409
+ </div>
410
+ </div>
411
+ </div>
412
+
413
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface/60 px-4 py-3.5">
414
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/50 mb-2">Operating Modes</div>
415
+ {capabilityHints.length > 0 ? (
416
+ <div className="flex flex-wrap gap-1.5">
417
+ {capabilityHints.map((hint) => (
418
+ <span key={hint} className="rounded-full bg-white/[0.06] px-2.5 py-1 text-[11px] font-600 text-text-2">
419
+ {hint}
420
+ </span>
421
+ ))}
422
+ </div>
423
+ ) : (
424
+ <p className="text-[12px] text-text-3/50">No capability hints yet. Add things like research, build, browsing, inbox ops, or credential bootstrapping.</p>
425
+ )}
426
+ </div>
427
+
428
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface/60 px-4 py-3.5">
429
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/50 mb-2">Success Metrics</div>
430
+ {successMetrics.length > 0 ? (
431
+ <div className="space-y-1.5">
432
+ {successMetrics.map((metric) => (
433
+ <div key={metric} className="flex items-start gap-2 text-[12px] text-text-2">
434
+ <span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-sky-400" />
435
+ <span>{metric}</span>
436
+ </div>
437
+ ))}
438
+ </div>
439
+ ) : (
440
+ <p className="text-[12px] text-text-3/50">Define success metrics if this project has open-ended goals like revenue, outreach, or inbox response quality.</p>
441
+ )}
442
+ </div>
443
+ </div>
444
+ ) : (
445
+ <div className="rounded-[12px] border border-dashed border-white/[0.08] px-5 py-6 text-center">
446
+ <p className="text-[13px] font-600 text-text-2">This project still needs operating context.</p>
447
+ <p className="mt-1 text-[12px] text-text-3/55">Add objective, open objectives, capability hints, credential needs, and heartbeat settings so agents can treat the project as a durable operating system.</p>
448
+ </div>
449
+ )}
450
+ </div>
451
+
452
+ <div className="rounded-[16px] border border-white/[0.06] bg-white/[0.02] px-5 py-5">
453
+ <div className="flex items-center justify-between mb-3">
454
+ <h3 className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Ops Readiness</h3>
455
+ <span className="text-[11px] text-text-3/40">{projectSecrets.length} secret{projectSecrets.length === 1 ? '' : 's'}</span>
456
+ </div>
457
+
458
+ <div className="grid grid-cols-2 gap-2 mb-4">
459
+ {[
460
+ { label: 'Linked secrets', value: projectSecrets.length, tone: 'text-emerald-400' },
461
+ { label: 'Credential reqs', value: credentialRequirements.length, tone: 'text-amber-400' },
462
+ { label: 'Heartbeat', value: project.heartbeatIntervalSec ? formatHeartbeatInterval(project.heartbeatIntervalSec) : 'Off', tone: 'text-sky-400' },
463
+ { label: 'Schedules', value: projectSchedules.length, tone: 'text-text-2' },
464
+ ].map((item) => (
465
+ <div key={item.label} className="rounded-[12px] border border-white/[0.06] bg-surface/60 px-3 py-3">
466
+ <div className={`text-[18px] font-display font-700 tracking-[-0.02em] ${item.tone}`}>{item.value}</div>
467
+ <div className="mt-1 text-[10px] font-600 uppercase tracking-[0.08em] text-text-3/45">{item.label}</div>
468
+ </div>
469
+ ))}
470
+ </div>
471
+
472
+ <div className="space-y-3">
473
+ <div>
474
+ <div className="text-[11px] font-600 text-text-3/50 mb-1">Credential requirements</div>
475
+ {credentialRequirements.length > 0 ? (
476
+ <div className="space-y-1.5">
477
+ {credentialRequirements.map((item) => (
478
+ <div key={item} className="flex items-start gap-2 text-[12px] text-text-2">
479
+ <span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
480
+ <span>{item}</span>
481
+ </div>
482
+ ))}
483
+ </div>
484
+ ) : (
485
+ <p className="text-[12px] text-text-3/50">No credentials requested yet.</p>
486
+ )}
487
+ </div>
488
+
489
+ <div>
490
+ <div className="text-[11px] font-600 text-text-3/50 mb-1">Heartbeat</div>
491
+ {project.heartbeatPrompt || project.heartbeatIntervalSec ? (
492
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface/60 px-3 py-3">
493
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-sky-400">
494
+ Every {formatHeartbeatInterval(project.heartbeatIntervalSec)}
495
+ </div>
496
+ <p className="mt-1 text-[12px] text-text-2 leading-relaxed">
497
+ {project.heartbeatPrompt || 'No heartbeat prompt configured.'}
498
+ </p>
499
+ </div>
500
+ ) : (
501
+ <p className="text-[12px] text-text-3/50">No project heartbeat configured.</p>
502
+ )}
503
+ </div>
504
+
505
+ <div className="flex flex-wrap gap-2 pt-1">
506
+ <button
507
+ onClick={() => { setEditingSecretId(null); setSecretSheetOpen(true) }}
508
+ className="px-3 py-2 rounded-[10px] bg-accent-soft text-[12px] font-600 text-accent-bright hover:bg-accent-bright/15 transition-all cursor-pointer border-none"
509
+ style={{ fontFamily: 'inherit' }}
510
+ >
511
+ Add project secret
512
+ </button>
513
+ <button
514
+ onClick={() => { setEditingScheduleId(null); setScheduleSheetOpen(true) }}
515
+ className="px-3 py-2 rounded-[10px] bg-white/[0.04] text-[12px] font-600 text-text-2 hover:bg-white/[0.08] transition-all cursor-pointer border-none"
516
+ style={{ fontFamily: 'inherit' }}
517
+ >
518
+ Add heartbeat schedule
519
+ </button>
520
+ </div>
521
+ </div>
522
+ </div>
523
+ </div>
524
+
308
525
  <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_280px] gap-4 mb-8">
309
526
  <div className="rounded-[16px] border border-white/[0.06] bg-white/[0.02] px-5 py-5">
310
527
  <div className="flex items-center justify-between gap-4 mb-4">