Merge remote-tracking branch 'origin/main' into feat/telegram-channel
# Conflicts: # apps/desktop/electron/electron-env.d.ts # apps/desktop/electron/ipc/index.ts # apps/desktop/electron/preload.ts # apps/desktop/src/App.tsx # apps/desktop/src/pages/layout.tsx # src/agent/async-agent.ts # src/agent/runner.ts # src/hub/hub.ts
This commit is contained in:
commit
23905daaa1
85 changed files with 7368 additions and 470 deletions
10
apps/desktop/electron/electron-env.d.ts
vendored
10
apps/desktop/electron/electron-env.d.ts
vendored
|
|
@ -205,6 +205,16 @@ interface ElectronAPI {
|
|||
stop: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
|
||||
start: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
|
||||
}
|
||||
cron: {
|
||||
list: () => Promise<unknown[]>
|
||||
toggle: (jobId: string) => Promise<{ ok: boolean }>
|
||||
remove: (jobId: string) => Promise<{ ok: boolean }>
|
||||
}
|
||||
heartbeat: {
|
||||
last: () => Promise<unknown>
|
||||
setEnabled: (enabled: boolean) => Promise<{ ok: boolean; enabled?: boolean; error?: string }>
|
||||
wake: (reason?: string) => Promise<{ ok: boolean; result?: unknown; error?: string }>
|
||||
}
|
||||
localChat: {
|
||||
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
|
||||
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const TOOL_GROUPS: Record<string, string[]> = {
|
|||
'group:web': ['web_search', 'web_fetch'],
|
||||
'group:memory': ['memory_search'],
|
||||
'group:subagent': ['sessions_spawn'],
|
||||
'group:cron': ['cron'],
|
||||
}
|
||||
|
||||
// All known tool names (for display when agent not available)
|
||||
|
|
@ -23,6 +24,7 @@ const ALL_KNOWN_TOOLS = [
|
|||
...TOOL_GROUPS['group:web'],
|
||||
...TOOL_GROUPS['group:memory'],
|
||||
...TOOL_GROUPS['group:subagent'],
|
||||
...TOOL_GROUPS['group:cron'],
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
68
apps/desktop/electron/ipc/cron.ts
Normal file
68
apps/desktop/electron/ipc/cron.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Cron IPC handlers for Electron main process.
|
||||
*
|
||||
* These handlers expose CronService operations to the renderer process
|
||||
* for the Cron Jobs management page.
|
||||
*/
|
||||
import { ipcMain } from 'electron'
|
||||
import { getCronService, formatSchedule } from '../../../../src/cron/index.js'
|
||||
|
||||
/**
|
||||
* Register all Cron-related IPC handlers.
|
||||
*/
|
||||
export function registerCronIpcHandlers(): void {
|
||||
/**
|
||||
* List all cron jobs with formatted display fields.
|
||||
*/
|
||||
ipcMain.handle('cron:list', async () => {
|
||||
const service = getCronService()
|
||||
const jobs = service.list()
|
||||
|
||||
return jobs.map((job) => ({
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
description: job.description,
|
||||
enabled: job.enabled,
|
||||
schedule: formatSchedule(job.schedule),
|
||||
sessionTarget: job.sessionTarget,
|
||||
nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null,
|
||||
lastStatus: job.state.lastStatus ?? null,
|
||||
lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null,
|
||||
lastDurationMs: job.state.lastDurationMs ?? null,
|
||||
lastError: job.state.lastError ?? null,
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggle a cron job's enabled status.
|
||||
*/
|
||||
ipcMain.handle('cron:toggle', async (_event, jobId: string) => {
|
||||
const service = getCronService()
|
||||
const job = service.get(jobId)
|
||||
if (!job) {
|
||||
return { error: `Job not found: ${jobId}` }
|
||||
}
|
||||
|
||||
const updated = service.update(jobId, { enabled: !job.enabled })
|
||||
if (!updated) {
|
||||
return { error: `Failed to update job: ${jobId}` }
|
||||
}
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
enabled: updated.enabled,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Remove a cron job.
|
||||
*/
|
||||
ipcMain.handle('cron:remove', async (_event, jobId: string) => {
|
||||
const service = getCronService()
|
||||
const removed = service.remove(jobId)
|
||||
if (!removed) {
|
||||
return { error: `Job not found: ${jobId}` }
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
39
apps/desktop/electron/ipc/heartbeat.ts
Normal file
39
apps/desktop/electron/ipc/heartbeat.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Heartbeat IPC handlers for Electron main process.
|
||||
*/
|
||||
import { ipcMain } from "electron";
|
||||
import { getCurrentHub } from "./hub.js";
|
||||
|
||||
export function registerHeartbeatIpcHandlers(): void {
|
||||
ipcMain.handle("heartbeat:last", async () => {
|
||||
const hub = getCurrentHub();
|
||||
if (!hub) return null;
|
||||
return hub.getLastHeartbeat();
|
||||
});
|
||||
|
||||
ipcMain.handle("heartbeat:setEnabled", async (_event, enabled: boolean) => {
|
||||
const hub = getCurrentHub();
|
||||
if (!hub) {
|
||||
return { ok: false, error: "Hub not initialized" };
|
||||
}
|
||||
if (typeof enabled !== "boolean") {
|
||||
return { ok: false, error: "enabled must be boolean" };
|
||||
}
|
||||
|
||||
hub.setHeartbeatsEnabled(enabled);
|
||||
return { ok: true, enabled };
|
||||
});
|
||||
|
||||
ipcMain.handle("heartbeat:wake", async (_event, reason?: string) => {
|
||||
const hub = getCurrentHub();
|
||||
if (!hub) {
|
||||
return { ok: false, error: "Hub not initialized" };
|
||||
}
|
||||
|
||||
const result = await hub.runHeartbeatOnce({
|
||||
reason: typeof reason === "string" ? reason.trim() || "manual" : "manual",
|
||||
});
|
||||
|
||||
return { ok: result.status !== "failed", result };
|
||||
});
|
||||
}
|
||||
|
|
@ -344,6 +344,9 @@ export function registerHubIpcHandlers(): void {
|
|||
* Get message history for local chat with pagination.
|
||||
* Returns raw AgentMessageItem[] so the renderer can render content blocks,
|
||||
* tool results, thinking blocks, etc. — same format as the Gateway RPC.
|
||||
*
|
||||
* Reads from session storage (not in-memory state) so that internal
|
||||
* orchestration messages are excluded by default.
|
||||
*/
|
||||
ipcMain.handle('localChat:getHistory', async (_event, agentId: string, options?: { offset?: number; limit?: number }) => {
|
||||
const h = getHub()
|
||||
|
|
@ -354,7 +357,7 @@ export function registerHubIpcHandlers(): void {
|
|||
|
||||
try {
|
||||
await agent.ensureInitialized()
|
||||
const allMessages = agent.getMessages()
|
||||
const allMessages = agent.loadSessionMessages()
|
||||
const total = allMessages.length
|
||||
// Must match DEFAULT_MESSAGES_LIMIT from @multica/sdk/actions/rpc
|
||||
const limit = options?.limit ?? 200
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmat
|
|||
export { registerProfileIpcHandlers } from './profile.js'
|
||||
export { registerProviderIpcHandlers } from './provider.js'
|
||||
export { registerChannelsIpcHandlers } from './channels.js'
|
||||
export { registerCronIpcHandlers } from './cron.js'
|
||||
export { registerHeartbeatIpcHandlers } from './heartbeat.js'
|
||||
|
||||
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
|
||||
import { registerSkillsIpcHandlers } from './skills.js'
|
||||
|
|
@ -14,6 +16,8 @@ import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js'
|
|||
import { registerProfileIpcHandlers } from './profile.js'
|
||||
import { registerProviderIpcHandlers } from './provider.js'
|
||||
import { registerChannelsIpcHandlers } from './channels.js'
|
||||
import { registerCronIpcHandlers } from './cron.js'
|
||||
import { registerHeartbeatIpcHandlers } from './heartbeat.js'
|
||||
|
||||
/**
|
||||
* Register all IPC handlers.
|
||||
|
|
@ -26,6 +30,8 @@ export function registerAllIpcHandlers(): void {
|
|||
registerProfileIpcHandlers()
|
||||
registerProviderIpcHandlers()
|
||||
registerChannelsIpcHandlers()
|
||||
registerCronIpcHandlers()
|
||||
registerHeartbeatIpcHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -221,6 +221,19 @@ const electronAPI = {
|
|||
ipcRenderer.invoke('channels:start', channelId, accountId),
|
||||
},
|
||||
|
||||
// Cron jobs management
|
||||
cron: {
|
||||
list: () => ipcRenderer.invoke('cron:list'),
|
||||
toggle: (jobId: string) => ipcRenderer.invoke('cron:toggle', jobId),
|
||||
remove: (jobId: string) => ipcRenderer.invoke('cron:remove', jobId),
|
||||
},
|
||||
|
||||
heartbeat: {
|
||||
last: () => ipcRenderer.invoke('heartbeat:last'),
|
||||
setEnabled: (enabled: boolean) => ipcRenderer.invoke('heartbeat:setEnabled', enabled),
|
||||
wake: (reason?: string) => ipcRenderer.invoke('heartbeat:wake', reason),
|
||||
},
|
||||
|
||||
// Local chat (direct IPC, no Gateway required)
|
||||
localChat: {
|
||||
/** Subscribe to agent events for local direct chat */
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import ChatPage from './pages/chat'
|
|||
import ToolsPage from './pages/tools'
|
||||
import SkillsPage from './pages/skills'
|
||||
import ChannelsPage from './pages/channels'
|
||||
import CronsPage from './pages/crons'
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
|
|
@ -16,6 +17,7 @@ const router = createHashRouter([
|
|||
{ path: 'tools', element: <ToolsPage /> },
|
||||
{ path: 'skills', element: <SkillsPage /> },
|
||||
{ path: 'channels', element: <ChannelsPage /> },
|
||||
{ path: 'crons', element: <CronsPage /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
|
|||
215
apps/desktop/src/components/cron-job-list.tsx
Normal file
215
apps/desktop/src/components/cron-job-list.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { useState } from 'react'
|
||||
import { Switch } from '@multica/ui/components/ui/switch'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
RotateClockwiseIcon,
|
||||
Delete02Icon,
|
||||
Loading03Icon,
|
||||
Time04Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
CancelCircleIcon,
|
||||
AlertCircleIcon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import type { CronJobInfo } from '../hooks/use-cron-jobs'
|
||||
|
||||
interface CronJobListProps {
|
||||
jobs: CronJobInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onToggleJob: (jobId: string) => Promise<void>
|
||||
onRemoveJob: (jobId: string) => Promise<void>
|
||||
onRefresh: () => Promise<void>
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: CronJobInfo['lastStatus'] }) {
|
||||
if (!status) {
|
||||
return (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
no runs
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const config = {
|
||||
ok: { icon: CheckmarkCircle02Icon, className: 'text-emerald-600', label: 'ok' },
|
||||
error: { icon: CancelCircleIcon, className: 'text-destructive', label: 'error' },
|
||||
skipped: { icon: AlertCircleIcon, className: 'text-yellow-600', label: 'skipped' },
|
||||
}[status]
|
||||
|
||||
return (
|
||||
<span className={`flex items-center gap-1 text-xs ${config.className}`}>
|
||||
<HugeiconsIcon icon={config.icon} className="size-3.5" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoString: string): string {
|
||||
const date = new Date(isoString)
|
||||
const now = Date.now()
|
||||
const diffMs = date.getTime() - now
|
||||
|
||||
if (Math.abs(diffMs) < 60_000) return 'just now'
|
||||
|
||||
const absMs = Math.abs(diffMs)
|
||||
const minutes = Math.floor(absMs / 60_000)
|
||||
const hours = Math.floor(absMs / 3_600_000)
|
||||
const days = Math.floor(absMs / 86_400_000)
|
||||
|
||||
const unit = days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : `${minutes}m`
|
||||
return diffMs > 0 ? `in ${unit}` : `${unit} ago`
|
||||
}
|
||||
|
||||
export function CronJobList({
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
onToggleJob,
|
||||
onRemoveJob,
|
||||
onRefresh,
|
||||
}: CronJobListProps) {
|
||||
const [togglingJobs, setTogglingJobs] = useState<Set<string>>(new Set())
|
||||
const [removingJobs, setRemovingJobs] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleToggle = async (jobId: string) => {
|
||||
setTogglingJobs((prev) => new Set(prev).add(jobId))
|
||||
try {
|
||||
await onToggleJob(jobId)
|
||||
} finally {
|
||||
setTogglingJobs((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(jobId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (jobId: string) => {
|
||||
setRemovingJobs((prev) => new Set(prev).add(jobId))
|
||||
try {
|
||||
await onRemoveJob(jobId)
|
||||
} finally {
|
||||
setRemovingJobs((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(jobId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && jobs.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading cron jobs...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{jobs.filter((j) => j.enabled).length} of {jobs.length} jobs enabled
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="gap-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={loading ? Loading03Icon : RotateClockwiseIcon}
|
||||
className={`size-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{jobs.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<HugeiconsIcon icon={Time04Icon} className="size-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No scheduled tasks</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Use the cron tool in Chat to create one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job list */}
|
||||
{jobs.length > 0 && (
|
||||
<div className="border rounded-lg divide-y">
|
||||
{jobs.map((job) => {
|
||||
const isToggling = togglingJobs.has(job.id)
|
||||
const isRemoving = removingJobs.has(job.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className="flex items-center gap-4 px-4 py-3 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{job.name}</span>
|
||||
<StatusBadge status={job.lastStatus} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{job.schedule}</span>
|
||||
{job.nextRunAt && job.enabled && (
|
||||
<span>next: {formatRelativeTime(job.nextRunAt)}</span>
|
||||
)}
|
||||
{job.lastRunAt && (
|
||||
<span>last: {formatRelativeTime(job.lastRunAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
{job.lastError && (
|
||||
<p className="text-xs text-destructive mt-0.5 truncate">{job.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemove(job.id)}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
{isRemoving ? (
|
||||
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{isToggling && (
|
||||
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Switch
|
||||
checked={job.enabled}
|
||||
onCheckedChange={() => handleToggle(job.id)}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CronJobList
|
||||
|
|
@ -11,6 +11,8 @@ import {
|
|||
ArrowDown01Icon,
|
||||
ArrowUp01Icon,
|
||||
Loading03Icon,
|
||||
Time04Icon,
|
||||
UserMultipleIcon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import type { ToolInfo, ToolGroup } from '../hooks/use-tools'
|
||||
|
||||
|
|
@ -20,6 +22,8 @@ const GROUP_ICONS: Record<string, typeof FolderOpenIcon> = {
|
|||
runtime: CodeIcon,
|
||||
web: GlobalIcon,
|
||||
memory: AiBrainIcon,
|
||||
subagent: UserMultipleIcon,
|
||||
cron: Time04Icon,
|
||||
other: CodeIcon,
|
||||
}
|
||||
|
||||
|
|
|
|||
107
apps/desktop/src/hooks/use-cron-jobs.ts
Normal file
107
apps/desktop/src/hooks/use-cron-jobs.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface CronJobInfo {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
schedule: string
|
||||
sessionTarget: string
|
||||
nextRunAt: string | null
|
||||
lastStatus: 'ok' | 'error' | 'skipped' | null
|
||||
lastRunAt: string | null
|
||||
lastDurationMs: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
export interface UseCronJobsReturn {
|
||||
jobs: CronJobInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
toggleJob: (jobId: string) => Promise<void>
|
||||
removeJob: (jobId: string) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useCronJobs(): UseCronJobsReturn {
|
||||
const [jobs, setJobs] = useState<CronJobInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchJobs = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = window.electronAPI
|
||||
? await window.electronAPI.cron.list()
|
||||
: await window.ipcRenderer.invoke('cron:list')
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
setJobs(result)
|
||||
} else {
|
||||
setError('Invalid response from cron:list')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch cron jobs')
|
||||
setJobs([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs()
|
||||
}, [fetchJobs])
|
||||
|
||||
const toggleJob = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const result = window.electronAPI
|
||||
? await window.electronAPI.cron.toggle(jobId)
|
||||
: await window.ipcRenderer.invoke('cron:toggle', jobId)
|
||||
|
||||
const typed = result as { error?: string; id?: string; enabled?: boolean }
|
||||
if (typed.error) {
|
||||
setError(typed.error)
|
||||
return
|
||||
}
|
||||
|
||||
setJobs((prev) =>
|
||||
prev.map((job) =>
|
||||
job.id === jobId ? { ...job, enabled: typed.enabled ?? !job.enabled } : job
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to toggle job')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeJob = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const result = window.electronAPI
|
||||
? await window.electronAPI.cron.remove(jobId)
|
||||
: await window.ipcRenderer.invoke('cron:remove', jobId)
|
||||
|
||||
const typed = result as { error?: string; ok?: boolean }
|
||||
if (typed.error) {
|
||||
setError(typed.error)
|
||||
return
|
||||
}
|
||||
|
||||
setJobs((prev) => prev.filter((job) => job.id !== jobId))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove job')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
toggleJob,
|
||||
removeJob,
|
||||
refresh: fetchJobs,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCronJobs
|
||||
70
apps/desktop/src/hooks/use-heartbeat.ts
Normal file
70
apps/desktop/src/hooks/use-heartbeat.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type HeartbeatEvent = {
|
||||
ts: number;
|
||||
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
|
||||
preview?: string;
|
||||
durationMs?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export function useHeartbeat() {
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [lastEvent, setLastEvent] = useState<HeartbeatEvent | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const event = (await window.electronAPI.heartbeat.last()) as HeartbeatEvent | null;
|
||||
setLastEvent(event);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, 15000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
const toggleEnabled = useCallback(async () => {
|
||||
const next = !enabled;
|
||||
const result = await window.electronAPI.heartbeat.setEnabled(next);
|
||||
if (result.ok) {
|
||||
setEnabled(next);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to update heartbeat setting");
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const wakeNow = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await window.electronAPI.heartbeat.wake("manual");
|
||||
if (!result.ok) {
|
||||
setError(result.error ?? "Failed to run heartbeat");
|
||||
}
|
||||
await refresh();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
lastEvent,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
toggleEnabled,
|
||||
wakeNow,
|
||||
};
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
|
|||
memory_set: 'Store a memory value',
|
||||
memory_delete: 'Delete a memory value',
|
||||
memory_list: 'List all memory keys',
|
||||
memory_search: 'Search memory files for keywords',
|
||||
cron: 'Create and manage scheduled tasks',
|
||||
}
|
||||
|
||||
// Group display names
|
||||
|
|
@ -39,6 +41,8 @@ const GROUP_NAMES: Record<string, string> = {
|
|||
runtime: 'Runtime',
|
||||
web: 'Web',
|
||||
memory: 'Memory',
|
||||
subagent: 'Subagent',
|
||||
cron: 'Cron',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
|
|
|
|||
43
apps/desktop/src/pages/crons.tsx
Normal file
43
apps/desktop/src/pages/crons.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@multica/ui/components/ui/card'
|
||||
import { useCronJobs } from '../hooks/use-cron-jobs'
|
||||
import { CronJobList } from '../components/cron-job-list'
|
||||
|
||||
export default function CronsPage() {
|
||||
const {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
toggleJob,
|
||||
removeJob,
|
||||
refresh,
|
||||
} = useCronJobs()
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cron Jobs</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage scheduled tasks. Create new jobs by asking the Agent in Chat.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CronJobList
|
||||
jobs={jobs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleJob={toggleJob}
|
||||
onRemoveJob={removeJob}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
Comment01Icon,
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
Comment01Icon,
|
||||
LinkSquare01Icon,
|
||||
Loading03Icon,
|
||||
AlertCircleIcon,
|
||||
|
|
@ -15,17 +15,17 @@ import {
|
|||
import { ConnectionQRCode } from '../components/qr-code'
|
||||
import { DeviceList } from '../components/device-list'
|
||||
import { AgentSettingsDialog } from '../components/agent-settings-dialog'
|
||||
import { ApiKeyDialog } from '../components/api-key-dialog'
|
||||
import { OAuthDialog } from '../components/oauth-dialog'
|
||||
import { useHub } from '../hooks/use-hub'
|
||||
import { useProvider } from '../hooks/use-provider'
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const { hubInfo, agents, loading, error } = useHub()
|
||||
const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider()
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [agentName, setAgentName] = useState<string | undefined>()
|
||||
import { ApiKeyDialog } from '../components/api-key-dialog'
|
||||
import { OAuthDialog } from '../components/oauth-dialog'
|
||||
import { useHub } from '../hooks/use-hub'
|
||||
import { useProvider } from '../hooks/use-provider'
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const { hubInfo, agents, loading, error } = useHub()
|
||||
const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider()
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [agentName, setAgentName] = useState<string | undefined>()
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
|
||||
|
|
@ -186,8 +186,8 @@ export default function HomePage() {
|
|||
<p className="font-medium">{agentName || 'Unnamed Agent'}</p>
|
||||
</div>
|
||||
|
||||
{/* Provider Selector */}
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
|
||||
{/* Provider Selector */}
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
|
||||
LLM Provider
|
||||
</p>
|
||||
|
|
@ -270,10 +270,10 @@ export default function HomePage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||
Gateway
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
PlugIcon,
|
||||
Comment01Icon,
|
||||
Share08Icon,
|
||||
Time04Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
|
||||
|
|
@ -20,6 +21,7 @@ const tabs = [
|
|||
{ path: '/tools', label: 'Tools', icon: CodeIcon },
|
||||
{ path: '/skills', label: 'Skills', icon: PlugIcon },
|
||||
{ path: '/channels', label: 'Channels', icon: Share08Icon },
|
||||
{ path: '/crons', label: 'Cron', icon: Time04Icon },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
|
|
|
|||
416
docs/design/auto-memory-refresh.md
Normal file
416
docs/design/auto-memory-refresh.md
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
# Auto Memory Refresh 实现方案
|
||||
|
||||
## 概述
|
||||
|
||||
在上下文压缩(compaction)发生之前,自动触发一个特殊的 agent turn,让 agent 分析即将被删除的消息,提取关键信息并写入 memory 文件,防止重要上下文丢失。
|
||||
|
||||
## OpenClaw 的实现分析
|
||||
|
||||
### 核心机制
|
||||
|
||||
OpenClaw 采用 **Pre-compaction Memory Flush** 策略:
|
||||
|
||||
```
|
||||
Session 运行中,token 累积
|
||||
↓
|
||||
totalTokens >= (contextWindow - reserveTokens - softThreshold)?
|
||||
↓ YES
|
||||
触发 Memory Flush Turn(特殊的 agent 对话轮次)
|
||||
↓
|
||||
Agent 分析会话,将重要信息保存到 memory/YYYY-MM-DD.md
|
||||
↓
|
||||
然后才执行 Compaction(删除旧消息)
|
||||
```
|
||||
|
||||
### 关键设计点
|
||||
|
||||
1. **Soft Threshold(软阈值)**
|
||||
- 默认值:`4000 tokens`
|
||||
- 触发条件:`totalTokens >= contextWindow - reserveTokens - softThreshold`
|
||||
- 在真正达到 compaction 阈值之前就触发 memory flush
|
||||
|
||||
2. **Memory Flush Prompt**
|
||||
```
|
||||
Pre-compaction memory flush. Store durable memories now
|
||||
(use memory/YYYY-MM-DD.md; create memory/ if needed).
|
||||
If nothing to store, reply with [SILENT].
|
||||
```
|
||||
|
||||
3. **防重复机制**
|
||||
- 使用 `memoryFlushCompactionCount` 追踪
|
||||
- 确保每次 compaction 周期只触发一次 flush
|
||||
|
||||
4. **Memory 文件结构**
|
||||
```
|
||||
~/.super-multica/agent-profiles/<profile-id>/
|
||||
├── memory.md # 主 memory 文件
|
||||
└── memory/
|
||||
├── 2024-01-15.md # 日期分片
|
||||
├── 2024-01-16.md
|
||||
└── topics/
|
||||
└── project-x.md # 主题分片
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Super Multica 实现方案
|
||||
|
||||
### Phase 1: 核心实现
|
||||
|
||||
#### 1.1 新增配置项
|
||||
|
||||
**文件:** `src/agent/session/session-manager.ts`
|
||||
|
||||
```typescript
|
||||
export type SessionManagerOptions = {
|
||||
// ... existing options ...
|
||||
|
||||
// Memory Flush 配置
|
||||
/** 是否启用自动 memory flush(默认:true) */
|
||||
enableMemoryFlush?: boolean | undefined;
|
||||
/** Memory flush 软阈值(在 compaction 前多少 tokens 触发),默认 4000 */
|
||||
memoryFlushSoftTokens?: number | undefined;
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.2 新增 Memory Flush 模块
|
||||
|
||||
**文件:** `src/agent/memory/memory-flush.ts`
|
||||
|
||||
```typescript
|
||||
/** Memory flush 配置 */
|
||||
export type MemoryFlushSettings = {
|
||||
/** 软阈值(tokens),在达到 compaction 阈值前多少 tokens 触发 */
|
||||
softThresholdTokens: number;
|
||||
/** Memory flush 使用的系统 prompt */
|
||||
systemPrompt: string;
|
||||
/** Memory flush 使用的用户 prompt */
|
||||
userPrompt: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_MEMORY_FLUSH_SETTINGS: MemoryFlushSettings = {
|
||||
softThresholdTokens: 4000,
|
||||
systemPrompt: `You are in a pre-compaction memory flush turn. The session is approaching context limit and old messages will be deleted soon.
|
||||
|
||||
Your task: Review the conversation and extract any important information that should be preserved in long-term memory. Focus on:
|
||||
- User preferences and settings
|
||||
- Key decisions made
|
||||
- Important technical details or solutions
|
||||
- Project-specific knowledge
|
||||
- Anything the user would want remembered in future sessions
|
||||
|
||||
Use the memory_write tool to save important information. If there's nothing worth saving, respond with [SILENT].`,
|
||||
|
||||
userPrompt: `[SYSTEM] Pre-compaction memory flush triggered. Please review recent conversation and save any important information to memory before context compression occurs.`,
|
||||
};
|
||||
|
||||
/** 检查是否应该触发 memory flush */
|
||||
export function shouldRunMemoryFlush(params: {
|
||||
currentTokens: number;
|
||||
contextWindowTokens: number;
|
||||
reserveTokens: number;
|
||||
softThresholdTokens: number;
|
||||
lastMemoryFlushCompactionCount?: number;
|
||||
currentCompactionCount: number;
|
||||
}): boolean {
|
||||
const {
|
||||
currentTokens,
|
||||
contextWindowTokens,
|
||||
reserveTokens,
|
||||
softThresholdTokens,
|
||||
lastMemoryFlushCompactionCount,
|
||||
currentCompactionCount,
|
||||
} = params;
|
||||
|
||||
// 如果当前 compaction 周期已经 flush 过,不再触发
|
||||
if (lastMemoryFlushCompactionCount === currentCompactionCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算 flush 阈值
|
||||
const flushThreshold = contextWindowTokens - reserveTokens - softThresholdTokens;
|
||||
|
||||
return currentTokens >= flushThreshold;
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 扩展 SessionEntry 类型
|
||||
|
||||
**文件:** `src/agent/session/types.ts`
|
||||
|
||||
```typescript
|
||||
export type SessionMeta = {
|
||||
// ... existing fields ...
|
||||
|
||||
/** 上次 memory flush 的时间戳 */
|
||||
memoryFlushAt?: number;
|
||||
/** 上次 memory flush 时的 compaction 计数 */
|
||||
memoryFlushCompactionCount?: number;
|
||||
/** Compaction 次数 */
|
||||
compactionCount?: number;
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.4 修改 Agent Runner
|
||||
|
||||
**文件:** `src/agent/runner.ts`
|
||||
|
||||
```typescript
|
||||
// 在 maybeCompact 之前检查并执行 memory flush
|
||||
private async maybeCompact() {
|
||||
const messages = this.agent.state.messages.slice();
|
||||
|
||||
// Phase 0: Check if memory flush is needed
|
||||
if (this.enableMemoryFlush) {
|
||||
const shouldFlush = shouldRunMemoryFlush({
|
||||
currentTokens: this.estimateCurrentTokens(messages),
|
||||
contextWindowTokens: this.contextWindowGuard.tokens,
|
||||
reserveTokens: this.reserveTokens,
|
||||
softThresholdTokens: this.memoryFlushSoftTokens,
|
||||
lastMemoryFlushCompactionCount: this.session.getMeta()?.memoryFlushCompactionCount,
|
||||
currentCompactionCount: this.session.getCompactionCount(),
|
||||
});
|
||||
|
||||
if (shouldFlush) {
|
||||
await this.runMemoryFlush();
|
||||
}
|
||||
}
|
||||
|
||||
// 继续原有的 compaction 逻辑...
|
||||
if (!this.session.needsCompaction(messages)) return;
|
||||
// ...
|
||||
}
|
||||
|
||||
private async runMemoryFlush() {
|
||||
this.emitMulticaEvent({ type: "memory_flush_start" });
|
||||
|
||||
try {
|
||||
// 创建一个临时的 agent turn 来执行 memory flush
|
||||
const flushResult = await this.executeMemoryFlushTurn();
|
||||
|
||||
// 更新 session metadata
|
||||
this.session.saveMeta({
|
||||
...this.session.getMeta(),
|
||||
memoryFlushAt: Date.now(),
|
||||
memoryFlushCompactionCount: this.session.getCompactionCount(),
|
||||
});
|
||||
|
||||
this.emitMulticaEvent({
|
||||
type: "memory_flush_end",
|
||||
saved: flushResult.memoriesSaved,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Agent] Memory flush failed:", error);
|
||||
// Memory flush 失败不阻塞 compaction
|
||||
}
|
||||
}
|
||||
|
||||
private async executeMemoryFlushTurn(): Promise<{ memoriesSaved: number }> {
|
||||
// 使用特殊的 system prompt 和 user prompt
|
||||
// 让 agent 分析当前会话并保存重要信息
|
||||
// 只允许使用 memory_write 工具
|
||||
|
||||
const originalSystemPrompt = this.agent.state.systemPrompt;
|
||||
const originalTools = this.agent.state.tools;
|
||||
|
||||
try {
|
||||
// 临时切换到 memory flush 模式
|
||||
this.agent.setSystemPrompt(this.memoryFlushSettings.systemPrompt);
|
||||
this.agent.setTools([this.memoryWriteTool]); // 只允许 memory_write
|
||||
|
||||
// 执行一个 agent turn
|
||||
await this.agent.run(this.memoryFlushSettings.userPrompt);
|
||||
|
||||
// 统计保存了多少 memory
|
||||
return { memoriesSaved: this.countMemoryWriteCalls() };
|
||||
} finally {
|
||||
// 恢复原始设置
|
||||
this.agent.setSystemPrompt(originalSystemPrompt);
|
||||
this.agent.setTools(originalTools);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.5 新增 Memory Flush Events
|
||||
|
||||
**文件:** `src/agent/events.ts`
|
||||
|
||||
```typescript
|
||||
/** Memory flush 开始事件 */
|
||||
export type MemoryFlushStartEvent = {
|
||||
type: "memory_flush_start";
|
||||
};
|
||||
|
||||
/** Memory flush 结束事件 */
|
||||
export type MemoryFlushEndEvent = {
|
||||
type: "memory_flush_end";
|
||||
/** 保存的 memory 条目数 */
|
||||
saved: number;
|
||||
};
|
||||
|
||||
/** Union of all Multica-specific events */
|
||||
export type MulticaEvent =
|
||||
| CompactionStartEvent
|
||||
| CompactionEndEvent
|
||||
| MemoryFlushStartEvent
|
||||
| MemoryFlushEndEvent;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Memory 文件管理
|
||||
|
||||
#### 2.1 Memory 文件结构
|
||||
|
||||
```
|
||||
~/.super-multica/agent-profiles/<profile-id>/
|
||||
├── identity.md # 身份设定(已有)
|
||||
├── memory.md # 主 memory 文件(已有)
|
||||
├── memory/ # Memory 分片目录(新增)
|
||||
│ ├── 2024-01-15.md # 日期分片
|
||||
│ ├── 2024-01-16.md
|
||||
│ └── ...
|
||||
└── sessions/ # Session 记录(已有)
|
||||
```
|
||||
|
||||
#### 2.2 Memory Write Tool 增强
|
||||
|
||||
**文件:** `src/agent/tools/memory/memory-write.ts`
|
||||
|
||||
```typescript
|
||||
// 支持写入日期分片
|
||||
export function resolveMemoryPath(
|
||||
profileDir: string,
|
||||
targetFile?: string
|
||||
): string {
|
||||
if (!targetFile) {
|
||||
// 默认写入今天的日期分片
|
||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const memoryDir = path.join(profileDir, 'memory');
|
||||
ensureDirSync(memoryDir);
|
||||
return path.join(memoryDir, `${today}.md`);
|
||||
}
|
||||
|
||||
// 支持指定文件
|
||||
if (targetFile.startsWith('memory/')) {
|
||||
return path.join(profileDir, targetFile);
|
||||
}
|
||||
|
||||
return path.join(profileDir, 'memory.md');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 前端集成
|
||||
|
||||
#### 3.1 SDK 事件类型
|
||||
|
||||
**文件:** `packages/sdk/src/types.ts`
|
||||
|
||||
```typescript
|
||||
export type LocalChatEvent =
|
||||
| MessageStartEvent
|
||||
| MessageUpdateEvent
|
||||
| MessageEndEvent
|
||||
| ToolExecutionStartEvent
|
||||
| ToolExecutionEndEvent
|
||||
| CompactionStartEvent
|
||||
| CompactionEndEvent
|
||||
| MemoryFlushStartEvent // 新增
|
||||
| MemoryFlushEndEvent; // 新增
|
||||
```
|
||||
|
||||
#### 3.2 Zustand Store
|
||||
|
||||
**文件:** `packages/store/src/agent-store.ts`
|
||||
|
||||
```typescript
|
||||
interface AgentState {
|
||||
// ... existing state ...
|
||||
|
||||
/** 是否正在进行 memory flush */
|
||||
memoryFlushing: boolean;
|
||||
/** 上次 memory flush 信息 */
|
||||
lastMemoryFlush: {
|
||||
timestamp: number;
|
||||
saved: number;
|
||||
} | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 Desktop UI 提示
|
||||
|
||||
在 compaction 提示之前显示 "正在保存重要记忆..."
|
||||
|
||||
---
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. **Step 1**: 新增 `memory-flush.ts` 模块,定义类型和判断逻辑
|
||||
2. **Step 2**: 扩展 `SessionMeta` 类型,添加 flush 相关字段
|
||||
3. **Step 3**: 新增 `MemoryFlushStartEvent` 和 `MemoryFlushEndEvent` 事件
|
||||
4. **Step 4**: 修改 `Agent` 类,添加 `runMemoryFlush` 方法
|
||||
5. **Step 5**: 修改 `maybeCompact` 流程,在 compaction 前检查并执行 flush
|
||||
6. **Step 6**: 增强 `memory_write` tool,支持日期分片
|
||||
7. **Step 7**: SDK 和 Store 集成
|
||||
8. **Step 8**: Desktop UI 提示
|
||||
|
||||
---
|
||||
|
||||
## 配置项汇总
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `enableMemoryFlush` | boolean | true | 是否启用自动 memory flush |
|
||||
| `memoryFlushSoftTokens` | number | 4000 | 在 compaction 阈值前多少 tokens 触发 |
|
||||
|
||||
---
|
||||
|
||||
## 与现有功能的关系
|
||||
|
||||
```
|
||||
Token 累积
|
||||
↓
|
||||
达到 Memory Flush 阈值? ──────────────────────┐
|
||||
↓ YES │
|
||||
Memory Flush Turn(新功能) │
|
||||
├─ Agent 分析会话 │
|
||||
├─ 调用 memory_write 保存重要信息 │
|
||||
└─ 更新 memoryFlushCompactionCount │
|
||||
↓ │
|
||||
达到 Compaction 阈值? ←───────────────────────┘
|
||||
↓ YES ↓ NO
|
||||
┌─────────────────────────────┐ │
|
||||
│ Tool Result Pruning(已实现)│ │
|
||||
│ Soft-trim / Hard-clear │ │
|
||||
└─────────────────────────────┘ │
|
||||
↓ │
|
||||
Message Compaction │
|
||||
├─ 删除旧消息 │
|
||||
└─ 或生成摘要 │
|
||||
↓ │
|
||||
继续会话 ←──────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 风险和注意事项
|
||||
|
||||
1. **Token 消耗**: Memory flush turn 本身会消耗 tokens,需要控制 prompt 长度
|
||||
2. **循环触发**: 需要 `memoryFlushCompactionCount` 防止重复触发
|
||||
3. **Tool 限制**: Flush turn 应该只允许 `memory_write`,防止执行其他操作
|
||||
4. **超时处理**: Flush turn 需要有超时机制,不能阻塞太久
|
||||
5. **静默响应**: 如果没有需要保存的内容,agent 应该返回 `[SILENT]` 跳过
|
||||
|
||||
---
|
||||
|
||||
## 测试计划
|
||||
|
||||
1. **单元测试**: `shouldRunMemoryFlush` 判断逻辑
|
||||
2. **集成测试**: Memory flush turn 执行流程
|
||||
3. **E2E 测试**: 完整的 flush → compaction 流程
|
||||
4. **边界测试**:
|
||||
- 连续多次 compaction 只触发一次 flush
|
||||
- Flush 失败不阻塞 compaction
|
||||
- Agent 返回 [SILENT] 时正常跳过
|
||||
823
docs/design/cron-job-tool.md
Normal file
823
docs/design/cron-job-tool.md
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
# Cron Job Tool 实现方案
|
||||
|
||||
## 概述
|
||||
|
||||
Cron Job Tool 允许 Agent 创建定时任务,在指定时间或周期性地执行操作。这对于提醒、定期检查、自动化工作流等场景非常有用。
|
||||
|
||||
## OpenClaw 实现分析
|
||||
|
||||
### 核心架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CronService │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ start() → 加载 jobs, 计算下次运行时间, 启动 timer │
|
||||
│ add() → 创建任务, 计算 schedule, 持久化 │
|
||||
│ update() → 修改任务, 重新计算 schedule │
|
||||
│ remove() → 删除任务 │
|
||||
│ run() → 立即执行任务 │
|
||||
│ list() → 列出所有任务 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Timer Loop │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ armTimer(nextWakeAtMs) │
|
||||
│ ↓ │
|
||||
│ onTimer() → runDueJobs() → executeJob() │
|
||||
│ ↓ │
|
||||
│ Update state, re-arm timer │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Job 类型
|
||||
|
||||
**Schedule 类型:**
|
||||
1. **at** - 一次性任务(指定时间戳)
|
||||
2. **every** - 固定间隔(如每 30 分钟)
|
||||
3. **cron** - 标准 cron 表达式(5 字段 + 可选时区)
|
||||
|
||||
**Session Target:**
|
||||
1. **main** - 注入到主会话(作为系统事件)
|
||||
2. **isolated** - 在独立会话中运行 agent turn
|
||||
|
||||
**Payload 类型:**
|
||||
1. **systemEvent** - 注入文本到主会话
|
||||
2. **agentTurn** - 在独立会话中执行 agent(可指定 model/thinking)
|
||||
|
||||
### 存储结构
|
||||
|
||||
```
|
||||
~/.openclaw/cron/
|
||||
├── jobs.json # 所有任务定义
|
||||
├── jobs.json.bak # 备份
|
||||
└── runs/
|
||||
├── <jobId-1>.jsonl # 任务1的运行历史
|
||||
└── <jobId-2>.jsonl # 任务2的运行历史
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Super Multica 实现方案
|
||||
|
||||
### Phase 1: 核心数据结构
|
||||
|
||||
#### 1.1 Job 类型定义
|
||||
|
||||
**文件:** `src/cron/types.ts`
|
||||
|
||||
```typescript
|
||||
import type { v7 as uuidv7 } from "uuid";
|
||||
|
||||
/** Cron 任务调度类型 */
|
||||
export type CronSchedule =
|
||||
| { kind: "at"; atMs: number } // 一次性(时间戳)
|
||||
| { kind: "every"; everyMs: number; anchorMs?: number } // 固定间隔
|
||||
| { kind: "cron"; expr: string; tz?: string }; // Cron 表达式
|
||||
|
||||
/** 任务执行目标 */
|
||||
export type CronSessionTarget = "main" | "isolated";
|
||||
|
||||
/** 唤醒模式 */
|
||||
export type CronWakeMode = "next-heartbeat" | "now";
|
||||
|
||||
/** 任务载荷 */
|
||||
export type CronPayload =
|
||||
| {
|
||||
kind: "system-event";
|
||||
text: string; // 注入到主会话的文本
|
||||
}
|
||||
| {
|
||||
kind: "agent-turn";
|
||||
message: string; // Agent 执行的 prompt
|
||||
model?: string; // 可选 model override
|
||||
thinkingLevel?: string; // 可选 thinking level
|
||||
timeoutSeconds?: number; // 超时时间
|
||||
};
|
||||
|
||||
/** 任务运行状态 */
|
||||
export type CronJobState = {
|
||||
nextRunAtMs?: number; // 下次运行时间
|
||||
runningAtMs?: number; // 正在运行的时间戳(锁)
|
||||
lastRunAtMs?: number; // 上次运行时间
|
||||
lastStatus?: "ok" | "error" | "skipped";
|
||||
lastError?: string;
|
||||
lastDurationMs?: number;
|
||||
};
|
||||
|
||||
/** Cron 任务定义 */
|
||||
export type CronJob = {
|
||||
id: string; // UUID
|
||||
name: string; // 用户友好名称
|
||||
description?: string; // 描述
|
||||
enabled: boolean; // 是否启用
|
||||
deleteAfterRun?: boolean; // 运行后自动删除(一次性任务)
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
schedule: CronSchedule;
|
||||
sessionTarget: CronSessionTarget;
|
||||
wakeMode: CronWakeMode;
|
||||
payload: CronPayload;
|
||||
state: CronJobState;
|
||||
};
|
||||
|
||||
/** 创建任务的输入 */
|
||||
export type CronJobInput = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" | "state">;
|
||||
|
||||
/** 运行日志条目 */
|
||||
export type CronRunLogEntry = {
|
||||
ts: number;
|
||||
jobId: string;
|
||||
action: "run" | "skip" | "error";
|
||||
status: "ok" | "error" | "skipped";
|
||||
error?: string;
|
||||
summary?: string;
|
||||
durationMs?: number;
|
||||
nextRunAtMs?: number;
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.2 存储层
|
||||
|
||||
**文件:** `src/cron/store.ts`
|
||||
|
||||
```typescript
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
import type { CronJob, CronRunLogEntry } from "./types.js";
|
||||
|
||||
const DEFAULT_CRON_DIR = path.join(
|
||||
process.env["HOME"] ?? ".",
|
||||
".super-multica",
|
||||
"cron"
|
||||
);
|
||||
|
||||
export class CronStore {
|
||||
private readonly jobsPath: string;
|
||||
private readonly runsDir: string;
|
||||
private jobs: Map<string, CronJob> = new Map();
|
||||
|
||||
constructor(baseDir: string = DEFAULT_CRON_DIR) {
|
||||
this.jobsPath = path.join(baseDir, "jobs.json");
|
||||
this.runsDir = path.join(baseDir, "runs");
|
||||
this.ensureDirs();
|
||||
}
|
||||
|
||||
private ensureDirs() {
|
||||
const dir = path.dirname(this.jobsPath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
if (!existsSync(this.runsDir)) mkdirSync(this.runsDir, { recursive: true });
|
||||
}
|
||||
|
||||
load(): CronJob[] {
|
||||
if (!existsSync(this.jobsPath)) return [];
|
||||
const data = JSON.parse(readFileSync(this.jobsPath, "utf-8"));
|
||||
this.jobs = new Map(data.jobs.map((j: CronJob) => [j.id, j]));
|
||||
return Array.from(this.jobs.values());
|
||||
}
|
||||
|
||||
save() {
|
||||
const jobs = Array.from(this.jobs.values());
|
||||
// Backup first
|
||||
if (existsSync(this.jobsPath)) {
|
||||
writeFileSync(this.jobsPath + ".bak", readFileSync(this.jobsPath));
|
||||
}
|
||||
writeFileSync(this.jobsPath, JSON.stringify({ jobs }, null, 2));
|
||||
}
|
||||
|
||||
get(id: string): CronJob | undefined {
|
||||
return this.jobs.get(id);
|
||||
}
|
||||
|
||||
set(job: CronJob) {
|
||||
this.jobs.set(job.id, job);
|
||||
this.save();
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const deleted = this.jobs.delete(id);
|
||||
if (deleted) this.save();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
list(filter?: { enabled?: boolean }): CronJob[] {
|
||||
let jobs = Array.from(this.jobs.values());
|
||||
if (filter?.enabled !== undefined) {
|
||||
jobs = jobs.filter((j) => j.enabled === filter.enabled);
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
// Run log methods
|
||||
appendRunLog(jobId: string, entry: CronRunLogEntry) {
|
||||
const logPath = path.join(this.runsDir, `${jobId}.jsonl`);
|
||||
const line = JSON.stringify(entry) + "\n";
|
||||
writeFileSync(logPath, line, { flag: "a" });
|
||||
}
|
||||
|
||||
getRunLogs(jobId: string, limit = 50): CronRunLogEntry[] {
|
||||
const logPath = path.join(this.runsDir, `${jobId}.jsonl`);
|
||||
if (!existsSync(logPath)) return [];
|
||||
const lines = readFileSync(logPath, "utf-8").trim().split("\n");
|
||||
return lines.slice(-limit).map((l) => JSON.parse(l));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Cron Service
|
||||
|
||||
**文件:** `src/cron/service.ts`
|
||||
|
||||
```typescript
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import Croner from "croner";
|
||||
import type { CronJob, CronJobInput, CronSchedule } from "./types.js";
|
||||
import { CronStore } from "./store.js";
|
||||
|
||||
export class CronService {
|
||||
private store: CronStore;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
|
||||
constructor(store?: CronStore) {
|
||||
this.store = store ?? new CronStore();
|
||||
}
|
||||
|
||||
/** 启动服务 */
|
||||
async start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.store.load();
|
||||
this.recomputeAllSchedules();
|
||||
this.armTimer();
|
||||
}
|
||||
|
||||
/** 停止服务 */
|
||||
stop() {
|
||||
this.running = false;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取服务状态 */
|
||||
status() {
|
||||
const jobs = this.store.list({ enabled: true });
|
||||
const nextWake = Math.min(
|
||||
...jobs.map((j) => j.state.nextRunAtMs ?? Infinity)
|
||||
);
|
||||
return {
|
||||
running: this.running,
|
||||
jobCount: jobs.length,
|
||||
nextWakeAtMs: nextWake === Infinity ? null : nextWake,
|
||||
};
|
||||
}
|
||||
|
||||
/** 列出任务 */
|
||||
list(filter?: { enabled?: boolean }) {
|
||||
return this.store.list(filter);
|
||||
}
|
||||
|
||||
/** 添加任务 */
|
||||
add(input: CronJobInput): CronJob {
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
...input,
|
||||
id: uuidv7(),
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
state: {},
|
||||
};
|
||||
this.computeNextRun(job);
|
||||
this.store.set(job);
|
||||
this.armTimer();
|
||||
return job;
|
||||
}
|
||||
|
||||
/** 更新任务 */
|
||||
update(id: string, patch: Partial<CronJobInput>): CronJob | null {
|
||||
const job = this.store.get(id);
|
||||
if (!job) return null;
|
||||
|
||||
Object.assign(job, patch, { updatedAtMs: Date.now() });
|
||||
if (patch.schedule) {
|
||||
this.computeNextRun(job);
|
||||
}
|
||||
this.store.set(job);
|
||||
this.armTimer();
|
||||
return job;
|
||||
}
|
||||
|
||||
/** 删除任务 */
|
||||
remove(id: string): boolean {
|
||||
return this.store.delete(id);
|
||||
}
|
||||
|
||||
/** 立即运行任务 */
|
||||
async run(id: string, force = false): Promise<{ ok: boolean; reason?: string }> {
|
||||
const job = this.store.get(id);
|
||||
if (!job) return { ok: false, reason: "Job not found" };
|
||||
if (!job.enabled && !force) return { ok: false, reason: "Job disabled" };
|
||||
|
||||
await this.executeJob(job);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** 获取运行日志 */
|
||||
getRunLogs(id: string) {
|
||||
return this.store.getRunLogs(id);
|
||||
}
|
||||
|
||||
// === Private Methods ===
|
||||
|
||||
private computeNextRun(job: CronJob) {
|
||||
const now = Date.now();
|
||||
let nextMs: number;
|
||||
|
||||
switch (job.schedule.kind) {
|
||||
case "at":
|
||||
nextMs = job.schedule.atMs;
|
||||
break;
|
||||
case "every":
|
||||
const anchor = job.schedule.anchorMs ?? now;
|
||||
const interval = job.schedule.everyMs;
|
||||
const elapsed = now - anchor;
|
||||
const periods = Math.ceil(elapsed / interval);
|
||||
nextMs = anchor + periods * interval;
|
||||
break;
|
||||
case "cron":
|
||||
const cron = Croner(job.schedule.expr, {
|
||||
timezone: job.schedule.tz,
|
||||
});
|
||||
const next = cron.nextRun();
|
||||
nextMs = next ? next.getTime() : now + 86400000; // fallback 1 day
|
||||
break;
|
||||
}
|
||||
|
||||
job.state.nextRunAtMs = nextMs;
|
||||
}
|
||||
|
||||
private recomputeAllSchedules() {
|
||||
for (const job of this.store.list({ enabled: true })) {
|
||||
this.computeNextRun(job);
|
||||
this.store.set(job);
|
||||
}
|
||||
}
|
||||
|
||||
private armTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
const jobs = this.store.list({ enabled: true });
|
||||
const nextWake = Math.min(
|
||||
...jobs.map((j) => j.state.nextRunAtMs ?? Infinity)
|
||||
);
|
||||
|
||||
if (nextWake === Infinity) return;
|
||||
|
||||
const delay = Math.max(0, nextWake - Date.now());
|
||||
this.timer = setTimeout(() => this.onTimer(), delay);
|
||||
}
|
||||
|
||||
private async onTimer() {
|
||||
const now = Date.now();
|
||||
const dueJobs = this.store
|
||||
.list({ enabled: true })
|
||||
.filter((j) => (j.state.nextRunAtMs ?? Infinity) <= now);
|
||||
|
||||
for (const job of dueJobs) {
|
||||
await this.executeJob(job);
|
||||
}
|
||||
|
||||
this.armTimer();
|
||||
}
|
||||
|
||||
private async executeJob(job: CronJob) {
|
||||
const startMs = Date.now();
|
||||
job.state.runningAtMs = startMs;
|
||||
this.store.set(job);
|
||||
|
||||
try {
|
||||
// TODO: 实际执行逻辑
|
||||
// - systemEvent: 注入到主会话
|
||||
// - agentTurn: 在独立会话中运行 agent
|
||||
console.log(`[Cron] Executing job: ${job.name} (${job.id})`);
|
||||
|
||||
// 模拟执行
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// 更新状态
|
||||
job.state.lastRunAtMs = startMs;
|
||||
job.state.lastStatus = "ok";
|
||||
job.state.lastDurationMs = Date.now() - startMs;
|
||||
job.state.runningAtMs = undefined;
|
||||
|
||||
// 一次性任务处理
|
||||
if (job.schedule.kind === "at") {
|
||||
if (job.deleteAfterRun) {
|
||||
this.store.delete(job.id);
|
||||
} else {
|
||||
job.enabled = false;
|
||||
}
|
||||
} else {
|
||||
this.computeNextRun(job);
|
||||
}
|
||||
|
||||
this.store.set(job);
|
||||
this.store.appendRunLog(job.id, {
|
||||
ts: startMs,
|
||||
jobId: job.id,
|
||||
action: "run",
|
||||
status: "ok",
|
||||
durationMs: job.state.lastDurationMs,
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
} catch (error) {
|
||||
job.state.lastRunAtMs = startMs;
|
||||
job.state.lastStatus = "error";
|
||||
job.state.lastError = String(error);
|
||||
job.state.lastDurationMs = Date.now() - startMs;
|
||||
job.state.runningAtMs = undefined;
|
||||
this.computeNextRun(job);
|
||||
this.store.set(job);
|
||||
this.store.appendRunLog(job.id, {
|
||||
ts: startMs,
|
||||
jobId: job.id,
|
||||
action: "error",
|
||||
status: "error",
|
||||
error: String(error),
|
||||
durationMs: job.state.lastDurationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Agent Tool
|
||||
|
||||
**文件:** `src/agent/tools/cron/cron-tool.ts`
|
||||
|
||||
```typescript
|
||||
import type { Tool } from "@mariozechner/pi-agent-core";
|
||||
import { CronService } from "../../../cron/service.js";
|
||||
|
||||
let cronService: CronService | null = null;
|
||||
|
||||
export function getCronService(): CronService {
|
||||
if (!cronService) {
|
||||
cronService = new CronService();
|
||||
cronService.start();
|
||||
}
|
||||
return cronService;
|
||||
}
|
||||
|
||||
export const cronTool: Tool = {
|
||||
name: "cron",
|
||||
description: `Create, manage, and execute scheduled tasks (cron jobs).
|
||||
|
||||
## Actions
|
||||
|
||||
### list
|
||||
List all cron jobs.
|
||||
\`\`\`json
|
||||
{ "action": "list", "enabled": true }
|
||||
\`\`\`
|
||||
|
||||
### add
|
||||
Create a new cron job.
|
||||
\`\`\`json
|
||||
{
|
||||
"action": "add",
|
||||
"name": "Daily reminder",
|
||||
"schedule": { "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" },
|
||||
"sessionTarget": "main",
|
||||
"wakeMode": "now",
|
||||
"payload": { "kind": "system-event", "text": "Check your todos!" }
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Schedule types:
|
||||
- \`{ "kind": "at", "atMs": 1704067200000 }\` - One-time at timestamp
|
||||
- \`{ "kind": "every", "everyMs": 3600000 }\` - Every hour
|
||||
- \`{ "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" }\` - Cron expression
|
||||
|
||||
### update
|
||||
Update an existing job.
|
||||
\`\`\`json
|
||||
{ "action": "update", "jobId": "xxx", "enabled": false }
|
||||
\`\`\`
|
||||
|
||||
### remove
|
||||
Delete a job.
|
||||
\`\`\`json
|
||||
{ "action": "remove", "jobId": "xxx" }
|
||||
\`\`\`
|
||||
|
||||
### run
|
||||
Execute a job immediately.
|
||||
\`\`\`json
|
||||
{ "action": "run", "jobId": "xxx", "force": true }
|
||||
\`\`\`
|
||||
|
||||
### status
|
||||
Get cron service status.
|
||||
\`\`\`json
|
||||
{ "action": "status" }
|
||||
\`\`\`
|
||||
`,
|
||||
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["list", "add", "update", "remove", "run", "status"],
|
||||
description: "The action to perform",
|
||||
},
|
||||
// list
|
||||
enabled: { type: "boolean", description: "Filter by enabled status" },
|
||||
// add
|
||||
name: { type: "string", description: "Job name" },
|
||||
description: { type: "string", description: "Job description" },
|
||||
schedule: { type: "object", description: "Schedule configuration" },
|
||||
sessionTarget: {
|
||||
type: "string",
|
||||
enum: ["main", "isolated"],
|
||||
description: "Where to run the job",
|
||||
},
|
||||
wakeMode: {
|
||||
type: "string",
|
||||
enum: ["next-heartbeat", "now"],
|
||||
description: "When to wake after job",
|
||||
},
|
||||
payload: { type: "object", description: "Job payload" },
|
||||
deleteAfterRun: { type: "boolean", description: "Delete after one-time run" },
|
||||
// update/remove/run
|
||||
jobId: { type: "string", description: "Job ID" },
|
||||
// run
|
||||
force: { type: "boolean", description: "Force run even if disabled" },
|
||||
},
|
||||
required: ["action"],
|
||||
},
|
||||
|
||||
execute: async (params: Record<string, unknown>) => {
|
||||
const service = getCronService();
|
||||
const action = params["action"] as string;
|
||||
|
||||
switch (action) {
|
||||
case "status":
|
||||
return JSON.stringify(service.status(), null, 2);
|
||||
|
||||
case "list":
|
||||
const jobs = service.list({
|
||||
enabled: params["enabled"] as boolean | undefined,
|
||||
});
|
||||
return JSON.stringify(jobs, null, 2);
|
||||
|
||||
case "add":
|
||||
const newJob = service.add({
|
||||
name: params["name"] as string,
|
||||
description: params["description"] as string | undefined,
|
||||
enabled: true,
|
||||
deleteAfterRun: params["deleteAfterRun"] as boolean | undefined,
|
||||
schedule: params["schedule"] as any,
|
||||
sessionTarget: (params["sessionTarget"] as any) ?? "main",
|
||||
wakeMode: (params["wakeMode"] as any) ?? "next-heartbeat",
|
||||
payload: params["payload"] as any,
|
||||
});
|
||||
return `Created job: ${newJob.name} (${newJob.id})\nNext run: ${new Date(newJob.state.nextRunAtMs!).toISOString()}`;
|
||||
|
||||
case "update":
|
||||
const updated = service.update(params["jobId"] as string, params as any);
|
||||
if (!updated) return "Job not found";
|
||||
return `Updated job: ${updated.name}`;
|
||||
|
||||
case "remove":
|
||||
const removed = service.remove(params["jobId"] as string);
|
||||
return removed ? "Job removed" : "Job not found";
|
||||
|
||||
case "run":
|
||||
const result = await service.run(
|
||||
params["jobId"] as string,
|
||||
params["force"] as boolean
|
||||
);
|
||||
return result.ok ? "Job executed" : `Failed: ${result.reason}`;
|
||||
|
||||
default:
|
||||
return `Unknown action: ${action}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: CLI 命令
|
||||
|
||||
**文件:** `src/agent/cli/commands/cron.ts`
|
||||
|
||||
```typescript
|
||||
import { Command } from "commander";
|
||||
import { getCronService } from "../../tools/cron/cron-tool.js";
|
||||
|
||||
export function registerCronCommands(program: Command) {
|
||||
const cron = program.command("cron").description("Manage cron jobs");
|
||||
|
||||
cron
|
||||
.command("status")
|
||||
.description("Show cron service status")
|
||||
.action(() => {
|
||||
const service = getCronService();
|
||||
const status = service.status();
|
||||
console.log("Cron Service Status:");
|
||||
console.log(` Running: ${status.running}`);
|
||||
console.log(` Jobs: ${status.jobCount}`);
|
||||
if (status.nextWakeAtMs) {
|
||||
console.log(` Next wake: ${new Date(status.nextWakeAtMs).toISOString()}`);
|
||||
}
|
||||
});
|
||||
|
||||
cron
|
||||
.command("list")
|
||||
.description("List all cron jobs")
|
||||
.option("--enabled", "Show only enabled jobs")
|
||||
.option("--disabled", "Show only disabled jobs")
|
||||
.action((opts) => {
|
||||
const service = getCronService();
|
||||
const enabled = opts.enabled ? true : opts.disabled ? false : undefined;
|
||||
const jobs = service.list({ enabled });
|
||||
|
||||
if (jobs.length === 0) {
|
||||
console.log("No cron jobs found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const job of jobs) {
|
||||
console.log(`\n${job.enabled ? "✓" : "✗"} ${job.name} (${job.id})`);
|
||||
console.log(` Schedule: ${formatSchedule(job.schedule)}`);
|
||||
console.log(` Target: ${job.sessionTarget}`);
|
||||
if (job.state.nextRunAtMs) {
|
||||
console.log(` Next run: ${new Date(job.state.nextRunAtMs).toISOString()}`);
|
||||
}
|
||||
if (job.state.lastStatus) {
|
||||
console.log(` Last run: ${job.state.lastStatus} (${job.state.lastDurationMs}ms)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cron
|
||||
.command("add")
|
||||
.description("Add a new cron job")
|
||||
.requiredOption("-n, --name <name>", "Job name")
|
||||
.option("--at <time>", "One-time at ISO timestamp or relative (e.g., '10m', '2h')")
|
||||
.option("--every <interval>", "Repeat interval (e.g., '30m', '1h', '1d')")
|
||||
.option("--cron <expr>", "Cron expression (5-field)")
|
||||
.option("--tz <timezone>", "Timezone for cron expression")
|
||||
.option("--message <text>", "System event text or agent prompt")
|
||||
.option("--isolated", "Run in isolated session")
|
||||
.option("--delete-after-run", "Delete after one-time run")
|
||||
.action((opts) => {
|
||||
const service = getCronService();
|
||||
|
||||
let schedule;
|
||||
if (opts.at) {
|
||||
schedule = { kind: "at" as const, atMs: parseTime(opts.at) };
|
||||
} else if (opts.every) {
|
||||
schedule = { kind: "every" as const, everyMs: parseInterval(opts.every) };
|
||||
} else if (opts.cron) {
|
||||
schedule = { kind: "cron" as const, expr: opts.cron, tz: opts.tz };
|
||||
} else {
|
||||
console.error("Must specify --at, --every, or --cron");
|
||||
return;
|
||||
}
|
||||
|
||||
const job = service.add({
|
||||
name: opts.name,
|
||||
enabled: true,
|
||||
deleteAfterRun: opts.deleteAfterRun,
|
||||
schedule,
|
||||
sessionTarget: opts.isolated ? "isolated" : "main",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "system-event",
|
||||
text: opts.message ?? "Cron job triggered",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Created job: ${job.name} (${job.id})`);
|
||||
console.log(`Next run: ${new Date(job.state.nextRunAtMs!).toISOString()}`);
|
||||
});
|
||||
|
||||
// ... more commands: run, remove, enable, disable, logs
|
||||
}
|
||||
|
||||
function formatSchedule(schedule: any): string {
|
||||
switch (schedule.kind) {
|
||||
case "at":
|
||||
return `at ${new Date(schedule.atMs).toISOString()}`;
|
||||
case "every":
|
||||
return `every ${schedule.everyMs}ms`;
|
||||
case "cron":
|
||||
return `cron "${schedule.expr}"${schedule.tz ? ` (${schedule.tz})` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTime(s: string): number {
|
||||
// Handle relative times like "10m", "2h"
|
||||
const match = s.match(/^(\d+)([smhd])$/);
|
||||
if (match) {
|
||||
const [, num, unit] = match;
|
||||
const ms = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
}[unit]!;
|
||||
return Date.now() + parseInt(num) * ms;
|
||||
}
|
||||
return new Date(s).getTime();
|
||||
}
|
||||
|
||||
function parseInterval(s: string): number {
|
||||
return parseTime(s) - Date.now();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现顺序
|
||||
|
||||
| Phase | 内容 | 优先级 |
|
||||
|-------|------|--------|
|
||||
| **1** | 类型定义 + 存储层 | P0 |
|
||||
| **2** | CronService 核心逻辑 | P0 |
|
||||
| **3** | Agent Tool (cron) | P0 |
|
||||
| **4** | CLI 命令 (multica cron) | P1 |
|
||||
| **5** | 独立会话执行 (isolated agent turn) | P1 |
|
||||
| **6** | Hub 集成 (Gateway API) | P2 |
|
||||
| **7** | Desktop UI 管理界面 | P2 |
|
||||
|
||||
---
|
||||
|
||||
## 与 OpenClaw 的差异
|
||||
|
||||
| 特性 | OpenClaw | Super Multica (建议) |
|
||||
|------|----------|---------------------|
|
||||
| 存储位置 | `~/.openclaw/cron/` | `~/.super-multica/cron/` |
|
||||
| 独立会话 | 完整实现 | Phase 1 先实现 main session |
|
||||
| 消息投递 | 支持 WhatsApp/Telegram 等 | 暂不实现 |
|
||||
| Gateway API | 完整实现 | Phase 2 实现 |
|
||||
| 并发控制 | maxConcurrentRuns | 暂时单线程执行 |
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# CLI: 10分钟后提醒
|
||||
multica cron add --name "Reminder" --at "10m" --message "Time to take a break!"
|
||||
|
||||
# CLI: 每天早上9点(北京时间)
|
||||
multica cron add --name "Morning check" --cron "0 9 * * *" --tz "Asia/Shanghai" \
|
||||
--message "Good morning! Check your tasks."
|
||||
|
||||
# CLI: 每30分钟
|
||||
multica cron add --name "Health check" --every "30m" --message "System health check"
|
||||
|
||||
# Agent Tool 调用
|
||||
{
|
||||
"action": "add",
|
||||
"name": "Daily standup reminder",
|
||||
"schedule": { "kind": "cron", "expr": "55 9 * * 1-5", "tz": "Asia/Shanghai" },
|
||||
"sessionTarget": "main",
|
||||
"payload": { "kind": "system-event", "text": "Standup meeting in 5 minutes!" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖
|
||||
|
||||
需要添加的依赖:
|
||||
```bash
|
||||
pnpm add croner # Cron 表达式解析库
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 风险和注意事项
|
||||
|
||||
1. **进程生命周期**: Desktop app 关闭后 timer 停止,需要重启时恢复
|
||||
2. **时区处理**: 使用 `croner` 库正确处理时区
|
||||
3. **并发安全**: 文件操作需要加锁防止竞争
|
||||
4. **内存泄漏**: 确保 timer 正确清理
|
||||
5. **错误恢复**: Job 执行失败不应影响其他 job
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
# App Store Submission Guide
|
||||
|
||||
Complete guide for publishing the Expo React Native app to Apple App Store and Google Play Store.
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### Accounts & Fees
|
||||
|
||||
| Platform | Cost | Notes |
|
||||
|----------|------|-------|
|
||||
| Apple Developer Program | $99/year | Required for App Store distribution |
|
||||
| Google Play Console | $25 one-time | Developer registration |
|
||||
| Expo Account | Free (paid plans available) | Required for EAS Build/Submit |
|
||||
|
||||
- Apple Developer account review: 1-2 days
|
||||
- Google Play developer account review: days to weeks
|
||||
|
||||
### Tools
|
||||
|
||||
```bash
|
||||
npm install -g eas-cli
|
||||
eas login
|
||||
eas whoami # verify login
|
||||
```
|
||||
|
||||
## 2. Project Configuration
|
||||
|
||||
### Initialize EAS
|
||||
|
||||
```bash
|
||||
eas build:configure
|
||||
```
|
||||
|
||||
Generates `eas.json` with three build profiles: `development`, `preview`, `production`.
|
||||
|
||||
### Key `app.json` / `app.config.ts` Fields
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "Multica",
|
||||
"slug": "multica",
|
||||
"version": "1.0.0",
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.multica.app",
|
||||
"buildNumber": "1" // increment on each submission
|
||||
},
|
||||
"android": {
|
||||
"package": "com.multica.app",
|
||||
"versionCode": 1 // increment on each submission
|
||||
},
|
||||
"icon": "./assets/icon.png", // 1024x1024 PNG
|
||||
"splash": {
|
||||
"image": "./assets/splash.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. App Signing & Credentials
|
||||
|
||||
### iOS
|
||||
|
||||
- EAS auto-manages credentials (recommended): Distribution Certificate + Provisioning Profile
|
||||
- Or create manually in Apple Developer Portal
|
||||
|
||||
### Android
|
||||
|
||||
- EAS auto-generates Keystore (recommended), stored securely on EAS servers
|
||||
- **Back up Keystore** — losing it means you cannot update the published app
|
||||
- Play Store requires AAB (Android App Bundle) format
|
||||
|
||||
## 4. Production Build
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
eas build --platform ios --profile production
|
||||
|
||||
# Android
|
||||
eas build --platform android --profile production
|
||||
|
||||
# Both
|
||||
eas build --platform all --profile production
|
||||
```
|
||||
|
||||
Builds run in Expo cloud — no local Xcode or Android Studio needed.
|
||||
|
||||
## 5. Store Listing Preparation
|
||||
|
||||
### Required for Both Platforms
|
||||
|
||||
#### Privacy Policy
|
||||
|
||||
- **Mandatory** — must be a publicly accessible URL
|
||||
- Must clearly state:
|
||||
- What data the app collects and how
|
||||
- Whether data is shared with third parties
|
||||
- Data retention and deletion policies
|
||||
- How users can request data deletion
|
||||
- **2025 rule**: If data is sent to third-party AI, must disclose explicitly and obtain user consent
|
||||
- Tools: Termly, PrivacyPolicies.com, or custom page
|
||||
|
||||
#### App Screenshots
|
||||
|
||||
- **iOS**: Multiple sizes required (6.7", 6.5", 5.5" iPhone + iPad)
|
||||
- **Android**: 2-8 screenshots
|
||||
- Must accurately reflect current app interface
|
||||
|
||||
#### App Icon
|
||||
|
||||
- 1024x1024 high-resolution PNG
|
||||
- No alpha/transparency for iOS
|
||||
|
||||
#### App Description
|
||||
|
||||
- Short description (≤80 chars for Google Play)
|
||||
- Full description
|
||||
|
||||
#### Support URL
|
||||
|
||||
- A link where users can get help
|
||||
|
||||
#### Account Deletion
|
||||
|
||||
- If the app supports registration, users **must** be able to delete their account and data in-app
|
||||
- Both Apple and Google require this
|
||||
|
||||
### Apple App Store Connect — Additional Requirements
|
||||
|
||||
| Item | Details |
|
||||
|------|---------|
|
||||
| Privacy Nutrition Labels | Fill out data collection practices per category in App Store Connect |
|
||||
| App Review Information | Reviewer contact info, demo/test account credentials |
|
||||
| Content Rating | Age classification |
|
||||
| Export Compliance | Encryption usage declaration |
|
||||
| Info.plist Permission Strings | Clear purpose description for each permission (camera, location, etc.) |
|
||||
|
||||
### Google Play Console — Additional Requirements
|
||||
|
||||
| Item | Details |
|
||||
|------|---------|
|
||||
| Data Safety Form | Detail data collection and sharing (required even if no data is collected) |
|
||||
| Content Rating Questionnaire | IARC rating questionnaire |
|
||||
| Target Audience | Declare if the app targets children |
|
||||
| First Upload | Must be done manually via Play Console (Google Play API limitation) |
|
||||
|
||||
## 6. Submit to Stores
|
||||
|
||||
### Apple App Store
|
||||
|
||||
```bash
|
||||
eas submit --platform ios
|
||||
```
|
||||
|
||||
This uploads the build to **App Store Connect / TestFlight**. Then you must:
|
||||
|
||||
1. Log into App Store Connect
|
||||
2. Select the uploaded build
|
||||
3. Associate it with a version
|
||||
4. Fill in all metadata, screenshots, privacy labels
|
||||
5. Submit for App Review
|
||||
|
||||
### Google Play Store
|
||||
|
||||
```bash
|
||||
eas submit --platform android
|
||||
```
|
||||
|
||||
**First time**: Must upload AAB manually in Play Console.
|
||||
|
||||
After initial upload:
|
||||
1. Navigate to Production → Create new release
|
||||
2. Upload AAB or use the EAS-submitted build
|
||||
3. Fill in description, screenshots, data safety form
|
||||
4. Submit for review
|
||||
|
||||
### Auto-Submit (Optional)
|
||||
|
||||
```bash
|
||||
eas build --platform all --profile production --auto-submit
|
||||
```
|
||||
|
||||
## 7. App Review
|
||||
|
||||
| | Apple | Google |
|
||||
|---|---|---|
|
||||
| Review time | Typically 24-48 hours | Hours to 7 days |
|
||||
| Common rejections | Incomplete features, misleading screenshots, missing privacy policy, unclear permission strings | Data safety form mismatch, policy violations |
|
||||
| After rejection | Fix issues, resubmit | Fix issues, resubmit |
|
||||
|
||||
## 8. Post-Launch
|
||||
|
||||
### OTA Updates (No Re-Review Needed)
|
||||
|
||||
```bash
|
||||
eas update --branch production
|
||||
```
|
||||
|
||||
- Only for JS/asset-level changes
|
||||
- Native code changes still require a new build + review
|
||||
|
||||
### CI/CD Automation
|
||||
|
||||
Create `.eas/workflows/build-and-submit.yml` to auto-build and submit on push to main.
|
||||
|
||||
### Google Service Account Key (for Automated Android Submissions)
|
||||
|
||||
1. Go to EAS dashboard → Credentials → Android
|
||||
2. Click Application identifier → Service Credentials
|
||||
3. Add Google Service Account Key
|
||||
|
||||
## 9. Checklist
|
||||
|
||||
- [ ] Register Apple Developer + Google Play Console accounts
|
||||
- [ ] Configure `app.json` and `eas.json`
|
||||
- [ ] Prepare app icon, splash screen, screenshots
|
||||
- [ ] Write and host privacy policy URL
|
||||
- [ ] Implement in-app account deletion (if registration exists)
|
||||
- [ ] Add Info.plist permission descriptions (iOS)
|
||||
- [ ] Run `eas build --platform all --profile production`
|
||||
- [ ] Create app in App Store Connect, fill metadata + privacy labels
|
||||
- [ ] Create app in Google Play Console, fill data safety form, manual first AAB upload
|
||||
- [ ] `eas submit` or submit manually for review
|
||||
- [ ] Wait for review approval → live
|
||||
- [ ] Set up `eas update` for OTA updates
|
||||
|
||||
## References
|
||||
|
||||
- [Expo: Submit to App Stores](https://docs.expo.dev/deploy/submit-to-app-stores/)
|
||||
- [Expo: EAS Submit](https://docs.expo.dev/submit/introduction/)
|
||||
- [Expo: Build Your Project](https://docs.expo.dev/deploy/build-project/)
|
||||
- [Expo: App Stores Best Practices](https://docs.expo.dev/distribution/app-stores/)
|
||||
- [Apple App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
|
||||
- [Apple App Privacy Details](https://developer.apple.com/app-store/app-privacy-details/)
|
||||
- [Google Play Data Safety](https://support.google.com/googleplay/android-developer/answer/10787469)
|
||||
- [Google Play Developer Policy Center](https://play.google/developer-content-policy/)
|
||||
497
docs/mobile/guide.md
Normal file
497
docs/mobile/guide.md
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
# Mobile Development Guide
|
||||
|
||||
Complete lifecycle guide for developing, testing, and publishing the Expo React Native app — from first line of code to App Store / Google Play.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
Phase 1: Environment Setup You are here if starting fresh
|
||||
↓
|
||||
Phase 2: Development & Testing Daily work loop
|
||||
↓
|
||||
Phase 3: Pre-Release Preparation Before your first submission
|
||||
↓
|
||||
Phase 4: Build & Submit Ship to stores
|
||||
↓
|
||||
Phase 5: Post-Launch Maintain and update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Environment Setup
|
||||
|
||||
### 1.1 Required Software
|
||||
|
||||
| Tool | Purpose | Install |
|
||||
|------|---------|---------|
|
||||
| **Node.js** (LTS) | JS runtime | `brew install node` or [nodejs.org](https://nodejs.org) |
|
||||
| **pnpm** | Package manager | `corepack enable && corepack prepare pnpm@latest --activate` |
|
||||
| **Xcode** | iOS build toolchain | Mac App Store (free) |
|
||||
| **Xcode Command Line Tools** | Compilers, simulators | `xcode-select --install` |
|
||||
| **CocoaPods** | iOS dependency manager | `sudo gem install cocoapods` |
|
||||
| **Android Studio** | Android emulator + SDK (optional, iOS-first) | [developer.android.com](https://developer.android.com/studio) |
|
||||
| **EAS CLI** | Expo build & submit | `npm install -g eas-cli` |
|
||||
| **Expo CLI** | Dev server | Bundled with `npx expo` |
|
||||
|
||||
### 1.2 Xcode First-Time Setup
|
||||
|
||||
1. Open Xcode at least once to accept the license and install components
|
||||
2. **Add your Apple ID** (free account is enough for development):
|
||||
- Xcode → Settings → Accounts → `+` → Apple ID
|
||||
- This creates a "Personal Team" for free code signing
|
||||
3. Verify simulators are installed:
|
||||
- Xcode → Settings → Components → download an iOS Simulator runtime
|
||||
|
||||
### 1.3 iPhone First-Time Setup (for Real Device Testing)
|
||||
|
||||
1. **Enable Developer Mode** (required on iOS 16+):
|
||||
- Settings → Privacy & Security → Developer Mode → ON
|
||||
- Device will restart
|
||||
2. Connect iPhone to Mac via USB/USB-C cable
|
||||
3. When prompted "Trust This Computer?" → tap Trust
|
||||
|
||||
### 1.4 Project Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Generate native project files (creates ios/ and android/ directories)
|
||||
npx expo prebuild
|
||||
|
||||
# Initialize EAS configuration (creates eas.json)
|
||||
eas build:configure
|
||||
```
|
||||
|
||||
### 1.5 Expo Account
|
||||
|
||||
```bash
|
||||
# Create account at expo.dev, then:
|
||||
eas login
|
||||
eas whoami # verify
|
||||
```
|
||||
|
||||
**No paid accounts needed at this stage.** Free Apple ID + free Expo account is enough for development.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Development & Testing
|
||||
|
||||
### 2.1 Running on iOS Simulator
|
||||
|
||||
```bash
|
||||
# Start the app in iOS simulator (no real device needed)
|
||||
npx expo run:ios
|
||||
```
|
||||
|
||||
- Fastest iteration loop — code changes hot-reload instantly
|
||||
- Good for: UI layout, navigation, business logic, API calls
|
||||
- **Cannot test**: camera, barcode scanner, real push notifications, biometrics
|
||||
|
||||
### 2.2 Running on Real iPhone
|
||||
|
||||
```bash
|
||||
# Connect iPhone via USB, then:
|
||||
npx expo run:ios --device
|
||||
```
|
||||
|
||||
Expo CLI will:
|
||||
1. Detect your connected device
|
||||
2. Sign the app with your Personal Team (free Apple ID)
|
||||
3. Build, install, and launch the app
|
||||
|
||||
**First time only**: After installation, go to:
|
||||
- Settings → General → VPN & Device Management → Trust your developer certificate
|
||||
|
||||
#### Free Signing Limitations
|
||||
|
||||
| Limitation | Detail |
|
||||
|-----------|--------|
|
||||
| 7-day expiry | App stops launching after 7 days — just re-run `npx expo run:ios --device` |
|
||||
| 3 devices max | Can register up to 3 test devices per Apple ID |
|
||||
| Some entitlements unavailable | Push notifications, Apple Pay, iCloud require paid account |
|
||||
| Cannot distribute to others | Only works on your own registered devices |
|
||||
|
||||
**Camera, barcode scanner, GPS, sensors all work fine with free signing.**
|
||||
|
||||
### 2.3 Daily Development Workflow
|
||||
|
||||
```
|
||||
First time (or after native config changes):
|
||||
npx expo prebuild Generate/update native projects
|
||||
npx expo run:ios --device Build and install on device
|
||||
|
||||
Every day after that:
|
||||
npx expo start --dev-client Start dev server only (no rebuild)
|
||||
→ Open the app on device It connects automatically
|
||||
→ Edit code, save Hot-reload updates instantly
|
||||
```
|
||||
|
||||
**When do you need to rebuild?**
|
||||
|
||||
| Change | Rebuild needed? |
|
||||
|--------|----------------|
|
||||
| JS/TS code, React components | No — hot-reload |
|
||||
| Styles, images, assets | No — hot-reload |
|
||||
| Added new Expo SDK module | **Yes** — `npx expo prebuild && npx expo run:ios --device` |
|
||||
| Changed `app.json` permissions | **Yes** — rebuild |
|
||||
| Updated native dependency | **Yes** — rebuild |
|
||||
| Upgraded Expo SDK version | **Yes** — rebuild |
|
||||
|
||||
### 2.4 Testing Native Features (Camera, Scanner)
|
||||
|
||||
| Feature | Simulator | Real Device |
|
||||
|---------|-----------|-------------|
|
||||
| Camera preview | Not available | Works |
|
||||
| Barcode / QR scan | Not available | Works |
|
||||
| GPS location | Simulated location via Xcode menu | Real GPS |
|
||||
| Push notifications | Not available | Requires paid Apple Developer account |
|
||||
| Haptic feedback | Not available | Works |
|
||||
| Device sensors (accelerometer, gyroscope) | Not available | Works |
|
||||
|
||||
For camera/scanner features, **always test on a real device**.
|
||||
|
||||
### 2.5 Debugging Tools
|
||||
|
||||
#### Developer Menu
|
||||
|
||||
Press `m` in the terminal (or shake the device) to open:
|
||||
- Toggle Performance Monitor
|
||||
- Toggle Element Inspector
|
||||
- Open React Native DevTools
|
||||
|
||||
#### React Native DevTools
|
||||
|
||||
The primary debugging tool (replaced Chrome DevTools since RN 0.76):
|
||||
|
||||
| Tab | Use |
|
||||
|-----|-----|
|
||||
| Console | View logs, execute JS in app context |
|
||||
| Sources | Set breakpoints, step through code |
|
||||
| Network | Inspect API requests (Expo only) |
|
||||
| Components | Inspect React component tree and props |
|
||||
| Profiler | Measure render performance |
|
||||
|
||||
#### VS Code Integration
|
||||
|
||||
Install the **Expo Tools** extension for:
|
||||
- Breakpoint debugging directly in VS Code
|
||||
- `app.json` / `app.config.ts` IntelliSense
|
||||
|
||||
#### Native Crash Debugging
|
||||
|
||||
For crashes in native modules (not JS):
|
||||
- **iOS**: Open Xcode → Window → Devices and Simulators → View Device Logs
|
||||
- **Android**: `adb logcat` in terminal
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Pre-Release Preparation
|
||||
|
||||
**This is when you need to start spending money.**
|
||||
|
||||
### 3.1 Accounts & Fees
|
||||
|
||||
| Platform | Cost | Registration Time | Required For |
|
||||
|----------|------|-------------------|--------------|
|
||||
| **Apple Developer Program** | $99/year | 1-2 days review | App Store distribution |
|
||||
| **Google Play Console** | $25 one-time | Days to weeks review | Play Store distribution |
|
||||
| **Expo Account** | Free tier sufficient | Instant | EAS Build & Submit |
|
||||
|
||||
Register early — account review takes time, especially Google.
|
||||
|
||||
### 3.2 App Configuration
|
||||
|
||||
Update `app.json` or `app.config.ts`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "Multica",
|
||||
"slug": "multica",
|
||||
"version": "1.0.0",
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.multica.app",
|
||||
"buildNumber": "1", // increment each submission
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Used to scan QR codes and take photos",
|
||||
"NSPhotoLibraryUsageDescription": "Used to save scanned images"
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "com.multica.app",
|
||||
"versionCode": 1, // increment each submission
|
||||
"permissions": ["CAMERA"]
|
||||
},
|
||||
"icon": "./assets/icon.png", // 1024x1024 PNG, no transparency
|
||||
"splash": {
|
||||
"image": "./assets/splash.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 EAS Build Profiles
|
||||
|
||||
`eas.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": { "version": ">= 10.0.0" },
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 App Signing & Credentials
|
||||
|
||||
#### iOS
|
||||
|
||||
EAS auto-manages credentials (recommended):
|
||||
- Distribution Certificate
|
||||
- Provisioning Profile
|
||||
- Or create manually in [Apple Developer Portal](https://developer.apple.com)
|
||||
|
||||
#### Android
|
||||
|
||||
- EAS auto-generates Keystore, stored securely on EAS servers
|
||||
- **Back up your Keystore** — losing it means you can never update the published app
|
||||
- Play Store requires AAB (Android App Bundle) format
|
||||
|
||||
### 3.5 Required Assets
|
||||
|
||||
| Asset | Spec |
|
||||
|-------|------|
|
||||
| **App Icon** | 1024x1024 PNG, no alpha/transparency (iOS) |
|
||||
| **Splash Screen** | Platform-appropriate sizes |
|
||||
| **iOS Screenshots** | 6.7", 6.5", 5.5" iPhone sizes + iPad (if universal) |
|
||||
| **Android Screenshots** | 2-8 screenshots |
|
||||
|
||||
### 3.6 Required Metadata
|
||||
|
||||
#### Both Platforms
|
||||
|
||||
| Item | Notes |
|
||||
|------|-------|
|
||||
| **Privacy Policy URL** | Publicly accessible. Must disclose data collection, third-party sharing, AI usage, deletion rights |
|
||||
| **App Description** | Short (≤80 chars for Google) + full description |
|
||||
| **Support URL** | Where users can get help |
|
||||
| **Account Deletion** | If app has registration, must support in-app account + data deletion |
|
||||
|
||||
#### Apple App Store Connect
|
||||
|
||||
| Item | Details |
|
||||
|------|---------|
|
||||
| Privacy Nutrition Labels | Data collection practices per category |
|
||||
| App Review Information | Reviewer contact info, demo/test account |
|
||||
| Content Rating | Age classification |
|
||||
| Export Compliance | Encryption usage declaration |
|
||||
| Info.plist Permission Strings | Clear purpose description for each permission |
|
||||
|
||||
#### Google Play Console
|
||||
|
||||
| Item | Details |
|
||||
|------|---------|
|
||||
| Data Safety Form | Required even if no data is collected |
|
||||
| Content Rating Questionnaire | IARC rating |
|
||||
| Target Audience | Must declare if targeting children |
|
||||
| First Upload | Must upload AAB manually (Google API limitation) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Build & Submit
|
||||
|
||||
### 4.1 Production Build
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
eas build --platform ios --profile production
|
||||
|
||||
# Android
|
||||
eas build --platform android --profile production
|
||||
|
||||
# Both platforms
|
||||
eas build --platform all --profile production
|
||||
```
|
||||
|
||||
Builds run in Expo cloud — no local Xcode or Android Studio needed for production builds.
|
||||
|
||||
### 4.2 Submit to Apple App Store
|
||||
|
||||
```bash
|
||||
eas submit --platform ios
|
||||
```
|
||||
|
||||
This uploads the build to **App Store Connect / TestFlight**. Then:
|
||||
|
||||
1. Log into [App Store Connect](https://appstoreconnect.apple.com)
|
||||
2. Select the uploaded build
|
||||
3. Associate it with a version
|
||||
4. Fill in all metadata, screenshots, privacy nutrition labels
|
||||
5. Submit for App Review
|
||||
|
||||
### 4.3 Submit to Google Play Store
|
||||
|
||||
```bash
|
||||
eas submit --platform android
|
||||
```
|
||||
|
||||
**First time**: Must upload AAB manually in [Play Console](https://play.google.com/console).
|
||||
|
||||
After initial upload:
|
||||
1. Navigate to Production → Create new release
|
||||
2. Upload AAB or use the EAS-submitted build
|
||||
3. Fill in description, screenshots, data safety form
|
||||
4. Submit for review
|
||||
|
||||
### 4.4 Auto-Submit (Optional)
|
||||
|
||||
Build and submit in one step:
|
||||
|
||||
```bash
|
||||
eas build --platform all --profile production --auto-submit
|
||||
```
|
||||
|
||||
### 4.5 App Review
|
||||
|
||||
| | Apple | Google |
|
||||
|---|---|---|
|
||||
| Review time | Typically 24-48 hours | Hours to 7 days |
|
||||
| Common rejections | Incomplete features, misleading screenshots, missing privacy policy, unclear permission strings | Data safety form mismatch, policy violations |
|
||||
| After rejection | Fix issues, resubmit | Fix issues, resubmit |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Post-Launch
|
||||
|
||||
### 5.1 OTA Updates (No Re-Review)
|
||||
|
||||
For JS/asset-only changes, push updates without going through App Review:
|
||||
|
||||
```bash
|
||||
eas update --branch production
|
||||
```
|
||||
|
||||
- Instant delivery to users — no store review
|
||||
- Only works for JavaScript and asset changes
|
||||
- **Native code changes still require a new build + review**
|
||||
|
||||
### 5.2 Version Bumping
|
||||
|
||||
For each new store submission:
|
||||
- iOS: increment `buildNumber` in `app.json`
|
||||
- Android: increment `versionCode` in `app.json`
|
||||
- Bump `version` for user-visible version changes
|
||||
|
||||
### 5.3 CI/CD Automation
|
||||
|
||||
Create `.eas/workflows/build-and-submit.yml` to auto-build and submit on push to main.
|
||||
|
||||
#### Google Service Account Key (Automated Android Submissions)
|
||||
|
||||
1. EAS dashboard → Credentials → Android
|
||||
2. Click Application identifier → Service Credentials
|
||||
3. Add Google Service Account Key
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npx expo prebuild # Generate native projects
|
||||
npx expo run:ios # Run on iOS simulator
|
||||
npx expo run:ios --device # Run on connected iPhone
|
||||
npx expo start --dev-client # Start dev server (after initial install)
|
||||
|
||||
# Building
|
||||
eas build --platform ios --profile development # Dev build (for device testing)
|
||||
eas build --platform ios --profile production # Production build
|
||||
eas build --platform all --profile production # Both platforms
|
||||
|
||||
# Submitting
|
||||
eas submit --platform ios # Submit to App Store
|
||||
eas submit --platform android # Submit to Play Store
|
||||
|
||||
# OTA Updates
|
||||
eas update --branch production # Push JS update to users
|
||||
```
|
||||
|
||||
### Cost Summary
|
||||
|
||||
| Phase | Cost |
|
||||
|-------|------|
|
||||
| Development + local testing | **Free** (free Apple ID + Xcode) |
|
||||
| EAS cloud builds | Free tier: 30 iOS + 30 Android builds/month |
|
||||
| App Store submission | **$99/year** (Apple Developer Program) |
|
||||
| Play Store submission | **$25 one-time** (Google Play Console) |
|
||||
|
||||
---
|
||||
|
||||
## Master Checklist
|
||||
|
||||
### Development Phase
|
||||
- [ ] Install Node.js, pnpm, Xcode, EAS CLI
|
||||
- [ ] Add Apple ID to Xcode (Settings → Accounts)
|
||||
- [ ] Enable Developer Mode on iPhone
|
||||
- [ ] Run `npx expo prebuild`
|
||||
- [ ] Test on simulator: `npx expo run:ios`
|
||||
- [ ] Test on real device: `npx expo run:ios --device`
|
||||
- [ ] Trust developer certificate on device
|
||||
- [ ] Verify camera/scanner functionality on real device
|
||||
|
||||
### Pre-Release Phase
|
||||
- [ ] Register Apple Developer Program ($99/year)
|
||||
- [ ] Register Google Play Console ($25)
|
||||
- [ ] Configure `app.json` (bundleIdentifier, permissions, icon, splash)
|
||||
- [ ] Configure `eas.json` build profiles
|
||||
- [ ] Prepare app icon (1024x1024 PNG)
|
||||
- [ ] Prepare splash screen
|
||||
- [ ] Take App Store screenshots (all required sizes)
|
||||
- [ ] Write and host privacy policy URL
|
||||
- [ ] Write app description (short + full)
|
||||
- [ ] Set up support URL
|
||||
- [ ] Implement in-app account deletion (if registration exists)
|
||||
|
||||
### Submission Phase
|
||||
- [ ] Run `eas build --platform all --profile production`
|
||||
- [ ] iOS: `eas submit --platform ios`
|
||||
- [ ] iOS: Fill metadata + privacy labels in App Store Connect
|
||||
- [ ] iOS: Submit for App Review
|
||||
- [ ] Android: Upload first AAB manually in Play Console
|
||||
- [ ] Android: `eas submit --platform android`
|
||||
- [ ] Android: Fill data safety form + metadata in Play Console
|
||||
- [ ] Android: Submit for review
|
||||
- [ ] Wait for review approval → app goes live
|
||||
|
||||
### Post-Launch Phase
|
||||
- [ ] Set up `eas update` for OTA updates
|
||||
- [ ] Set up CI/CD workflow (optional)
|
||||
- [ ] Configure Google Service Account Key for automated Android submissions (optional)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Expo: Getting Started](https://docs.expo.dev/get-started/introduction/)
|
||||
- [Expo: Development Builds](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Expo: Local App Development](https://docs.expo.dev/guides/local-app-development/)
|
||||
- [Expo: Debugging Tools](https://docs.expo.dev/debugging/tools/)
|
||||
- [Expo: Submit to App Stores](https://docs.expo.dev/deploy/submit-to-app-stores/)
|
||||
- [Expo: EAS Submit](https://docs.expo.dev/submit/introduction/)
|
||||
- [Expo: EAS Update](https://docs.expo.dev/eas-update/introduction/)
|
||||
- [Apple App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
|
||||
- [Apple App Privacy Details](https://developer.apple.com/app-store/app-privacy-details/)
|
||||
- [Google Play Data Safety](https://support.google.com/googleplay/android-developer/answer/10787469)
|
||||
- [Google Play Developer Policy Center](https://play.google/developer-content-policy/)
|
||||
|
|
@ -60,6 +60,7 @@
|
|||
"@nestjs/serve-static": "^5.0.4",
|
||||
"@nestjs/websockets": "^11.1.12",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"croner": "^10.0.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"grammy": "^1.39.3",
|
||||
"json5": "^2.2.3",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export interface ExecApprovalRequestPayload {
|
|||
riskLevel: "safe" | "needs-review" | "dangerous";
|
||||
/** Reasons for the risk assessment */
|
||||
riskReasons: string[];
|
||||
/** When this approval expires (ms since epoch) */
|
||||
/** When this approval expires (ms since epoch). -1 means no timeout. */
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,17 @@ export interface ExecApprovalItemProps {
|
|||
onDecision: (decision: "allow-once" | "allow-always" | "deny") => void
|
||||
}
|
||||
|
||||
function useCountdown(expiresAtMs: number): number {
|
||||
const [remaining, setRemaining] = useState(() =>
|
||||
Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)),
|
||||
function useCountdown(expiresAtMs: number): number | null {
|
||||
const [remaining, setRemaining] = useState<number | null>(() =>
|
||||
expiresAtMs < 0 ? null : Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (expiresAtMs < 0) {
|
||||
setRemaining(null)
|
||||
return
|
||||
}
|
||||
|
||||
const id = setInterval(() => {
|
||||
const next = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000))
|
||||
setRemaining(next)
|
||||
|
|
@ -73,7 +78,7 @@ export const ExecApprovalItem = memo(function ExecApprovalItem({
|
|||
<HugeiconsIcon icon={CommandLineIcon} strokeWidth={2} className="size-3.5 shrink-0" />
|
||||
<span className="font-medium text-foreground">{riskLabel}</span>
|
||||
</div>
|
||||
{remaining > 0 && !decided && (
|
||||
{remaining !== null && remaining > 0 && !decided && (
|
||||
<span className="text-xs text-muted-foreground/60 font-[tabular-nums]">
|
||||
{remaining}s
|
||||
</span>
|
||||
|
|
@ -100,7 +105,7 @@ export const ExecApprovalItem = memo(function ExecApprovalItem({
|
|||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{!decided && remaining > 0 ? (
|
||||
{!decided && (remaining === null || remaining > 0) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
143
pnpm-lock.yaml
generated
143
pnpm-lock.yaml
generated
|
|
@ -34,13 +34,13 @@ importers:
|
|||
dependencies:
|
||||
'@mariozechner/pi-agent-core':
|
||||
specifier: ^0.50.3
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.50.3
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.50.3
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mozilla/readability':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
|
|
@ -68,6 +68,9 @@ importers:
|
|||
'@sinclair/typebox':
|
||||
specifier: ^0.34.41
|
||||
version: 0.34.48
|
||||
croner:
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1
|
||||
fast-glob:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
|
|
@ -291,7 +294,7 @@ importers:
|
|||
version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
expo-router:
|
||||
specifier: ~6.0.23
|
||||
version: 6.0.23(w7u2e3gmpia3npio76ytuzityu)
|
||||
version: 6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3)
|
||||
expo-splash-screen:
|
||||
specifier: ~31.0.13
|
||||
version: 31.0.13(expo@54.0.33)
|
||||
|
|
@ -1911,89 +1914,105 @@ packages:
|
|||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
|
|
@ -2158,24 +2177,28 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@mariozechner/clipboard-linux-riscv64-gnu@0.3.0':
|
||||
resolution: {integrity: sha512-4BC08CIaOXSSAGRZLEjqJmQfioED8ohAzwt0k2amZPEbH96YKoBNorq5EdwPf5VT+odS0DeyCwhwtxokRLZIvQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@mariozechner/clipboard-linux-x64-gnu@0.3.0':
|
||||
resolution: {integrity: sha512-GpNY5Y9nOzr0Vt0Qi5U88qwe6piiIHk44kSMexl8ns90LluN5UTNYmyfi7Xq3/lmPZCpnB2xvBTYbsXCxnopIA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@mariozechner/clipboard-linux-x64-musl@0.3.0':
|
||||
resolution: {integrity: sha512-+PnR48/x9GMY5Kh8BLjzHMx6trOegMtxAuqTM9X/bhV3QuW6sLLd7nojDHSGj/ZueK6i0tcQxvOrgNLozVtNDA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@mariozechner/clipboard-win32-arm64-msvc@0.3.0':
|
||||
resolution: {integrity: sha512-+dy2vZ1Ph4EYj0cotB+bVUVk/uKl2bh9LOp/zlnFqoCCYDN6sm+L0VyIOPPo3hjoEVdGpHe1MUxp3qG/OLwXgg==}
|
||||
|
|
@ -2337,24 +2360,28 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.1.6':
|
||||
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.1.6':
|
||||
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
|
||||
|
|
@ -2818,66 +2845,79 @@ packages:
|
|||
resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.57.0':
|
||||
resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.57.0':
|
||||
resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.57.0':
|
||||
resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.57.0':
|
||||
resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.57.0':
|
||||
resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.57.0':
|
||||
resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.57.0':
|
||||
resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==}
|
||||
|
|
@ -3202,24 +3242,28 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||
|
|
@ -3734,41 +3778,49 @@ packages:
|
|||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
|
|
@ -4591,6 +4643,10 @@ packages:
|
|||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
croner@10.0.1:
|
||||
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
|
||||
engines: {node: '>=18.0'}
|
||||
|
||||
cross-fetch@3.2.0:
|
||||
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
|
||||
|
||||
|
|
@ -6467,48 +6523,56 @@ packages:
|
|||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.27.0:
|
||||
resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.27.0:
|
||||
resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.27.0:
|
||||
resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.27.0:
|
||||
resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==}
|
||||
|
|
@ -9386,12 +9450,6 @@ snapshots:
|
|||
package-manager-detector: 1.6.0
|
||||
tinyexec: 1.0.2
|
||||
|
||||
'@anthropic-ai/sdk@0.71.2(zod@3.25.76)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@anthropic-ai/sdk@0.71.2(zod@4.3.6)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
|
|
@ -10920,7 +10978,7 @@ snapshots:
|
|||
wrap-ansi: 7.0.0
|
||||
ws: 8.18.3
|
||||
optionalDependencies:
|
||||
expo-router: 6.0.23(w7u2e3gmpia3npio76ytuzityu)
|
||||
expo-router: 6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3)
|
||||
react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
|
|
@ -11169,17 +11227,6 @@ snapshots:
|
|||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))':
|
||||
dependencies:
|
||||
google-auth-library: 10.5.0
|
||||
ws: 8.18.3
|
||||
optionalDependencies:
|
||||
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))':
|
||||
dependencies:
|
||||
google-auth-library: 10.5.0
|
||||
|
|
@ -11522,19 +11569,6 @@ snapshots:
|
|||
std-env: 3.10.0
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
'@mariozechner/pi-tui': 0.50.3
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
|
|
@ -11548,30 +11582,6 @@ snapshots:
|
|||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@3.25.76)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.978.0
|
||||
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))
|
||||
'@mistralai/mistralai': 1.10.0
|
||||
'@sinclair/typebox': 0.34.48
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
chalk: 5.6.2
|
||||
openai: 6.10.0(ws@8.18.3)(zod@3.25.76)
|
||||
partial-json: 0.1.7
|
||||
proxy-agent: 6.5.0
|
||||
undici: 7.19.2
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.6)
|
||||
|
|
@ -11596,12 +11606,12 @@ snapshots:
|
|||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
|
||||
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/clipboard': 0.3.0
|
||||
'@mariozechner/jiti': 2.6.5
|
||||
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.50.3
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
chalk: 5.6.2
|
||||
|
|
@ -14333,6 +14343,8 @@ snapshots:
|
|||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
croner@10.0.1: {}
|
||||
|
||||
cross-fetch@3.2.0:
|
||||
dependencies:
|
||||
node-fetch: 2.7.0
|
||||
|
|
@ -15303,7 +15315,7 @@ snapshots:
|
|||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
expo-router@6.0.23(w7u2e3gmpia3npio76ytuzityu):
|
||||
expo-router@6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3):
|
||||
dependencies:
|
||||
'@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@expo/schema-utils': 0.1.8
|
||||
|
|
@ -17715,11 +17727,6 @@ snapshots:
|
|||
is-docker: 2.2.1
|
||||
is-wsl: 2.2.0
|
||||
|
||||
openai@6.10.0(ws@8.18.3)(zod@3.25.76):
|
||||
optionalDependencies:
|
||||
ws: 8.18.3
|
||||
zod: 3.25.76
|
||||
|
||||
openai@6.10.0(ws@8.18.3)(zod@4.3.6):
|
||||
optionalDependencies:
|
||||
ws: 8.18.3
|
||||
|
|
|
|||
264
src/agent/async-agent.test.ts
Normal file
264
src/agent/async-agent.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AsyncAgent } from "./async-agent.js";
|
||||
|
||||
const subscribeCallbacks: Array<(event: any) => void> = [];
|
||||
const internalRunState = { value: false };
|
||||
|
||||
const runMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined }));
|
||||
const runInternalMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined }));
|
||||
const flushSessionMock = vi.fn(async () => {});
|
||||
const persistAssistantSummaryMock = vi.fn();
|
||||
const subscribeAllMock = vi.fn((fn: (event: any) => void) => {
|
||||
subscribeCallbacks.push(fn);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
vi.mock("./runner.js", () => ({
|
||||
Agent: class MockAgent {
|
||||
sessionId = "test-session";
|
||||
subscribeAll = subscribeAllMock;
|
||||
run = runMock;
|
||||
runInternal = runInternalMock;
|
||||
flushSession = flushSessionMock;
|
||||
persistAssistantSummary = persistAssistantSummaryMock;
|
||||
get isInternalRun() {
|
||||
return internalRunState.value;
|
||||
}
|
||||
getMessages() {
|
||||
return [];
|
||||
}
|
||||
loadSessionMessages() {
|
||||
return [];
|
||||
}
|
||||
async ensureInitialized() {}
|
||||
getActiveTools() {
|
||||
return [];
|
||||
}
|
||||
reloadTools() {
|
||||
return [];
|
||||
}
|
||||
getSkillsWithStatus() {
|
||||
return [];
|
||||
}
|
||||
getEligibleSkills() {
|
||||
return [];
|
||||
}
|
||||
reloadSkills() {}
|
||||
setToolStatus() {
|
||||
return undefined;
|
||||
}
|
||||
getProfileId() {
|
||||
return undefined;
|
||||
}
|
||||
getAgentName() {
|
||||
return undefined;
|
||||
}
|
||||
setAgentName() {}
|
||||
getUserContent() {
|
||||
return undefined;
|
||||
}
|
||||
setUserContent() {}
|
||||
getAgentStyle() {
|
||||
return undefined;
|
||||
}
|
||||
setAgentStyle() {}
|
||||
reloadSystemPrompt() {}
|
||||
getProviderInfo() {
|
||||
return { provider: "test", model: "test-model" };
|
||||
}
|
||||
setProvider() {
|
||||
return { provider: "test", model: "test-model" };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
async function nextWithTimeout<T>(iter: AsyncIterator<T>, timeoutMs = 40): Promise<"timeout" | T> {
|
||||
return await Promise.race([
|
||||
iter.next().then((result) => (result.done ? "timeout" : result.value)),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("AsyncAgent internal flow", () => {
|
||||
afterEach(() => {
|
||||
subscribeCallbacks.length = 0;
|
||||
internalRunState.value = false;
|
||||
runMock.mockReset();
|
||||
runInternalMock.mockReset();
|
||||
flushSessionMock.mockReset();
|
||||
persistAssistantSummaryMock.mockReset();
|
||||
subscribeAllMock.mockClear();
|
||||
runMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined });
|
||||
runInternalMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined });
|
||||
flushSessionMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("filters internal events in direct subscribe stream", () => {
|
||||
const agent = new AsyncAgent();
|
||||
const events: Array<{ type: string }> = [];
|
||||
|
||||
const unsubscribe = agent.subscribe((event) => {
|
||||
events.push(event as { type: string });
|
||||
});
|
||||
|
||||
// subscribeAll is called twice:
|
||||
// 1) constructor for read() channel forwarding
|
||||
// 2) subscribe() for direct callback forwarding
|
||||
const subscribeCallback = subscribeCallbacks[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
|
||||
internalRunState.value = true;
|
||||
subscribeCallback!({ type: "message_end" });
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
internalRunState.value = false;
|
||||
subscribeCallback!({ type: "message_end" });
|
||||
expect(events).toHaveLength(1);
|
||||
|
||||
unsubscribe();
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("does not leak internal run errors to read() stream", async () => {
|
||||
runInternalMock.mockResolvedValueOnce({ text: "", thinking: undefined, error: "internal failed" });
|
||||
const agent = new AsyncAgent();
|
||||
const iter = agent.read()[Symbol.asyncIterator]();
|
||||
|
||||
agent.writeInternal("test internal");
|
||||
await agent.waitForIdle();
|
||||
|
||||
const value = await nextWithTimeout(iter);
|
||||
expect(value).toBe("timeout");
|
||||
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("does not leak internal run exceptions to read() stream", async () => {
|
||||
runInternalMock.mockRejectedValueOnce(new Error("internal exception"));
|
||||
const agent = new AsyncAgent();
|
||||
const iter = agent.read()[Symbol.asyncIterator]();
|
||||
|
||||
agent.writeInternal("test internal");
|
||||
await agent.waitForIdle();
|
||||
|
||||
const value = await nextWithTimeout(iter);
|
||||
expect(value).toBe("timeout");
|
||||
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("forwards assistant message stream (start/update/end) when writeInternal opts in", async () => {
|
||||
let resolveRunInternal: ((value: { text: string; thinking: undefined; error: undefined }) => void) | undefined;
|
||||
runInternalMock.mockImplementationOnce(
|
||||
() => new Promise((resolve) => {
|
||||
resolveRunInternal = resolve as typeof resolveRunInternal;
|
||||
}),
|
||||
);
|
||||
|
||||
const agent = new AsyncAgent();
|
||||
const iter = agent.read()[Symbol.asyncIterator]();
|
||||
const streamCallback = subscribeCallbacks[0];
|
||||
expect(streamCallback).toBeDefined();
|
||||
|
||||
agent.writeInternal("announce", { forwardAssistant: true });
|
||||
await Promise.resolve();
|
||||
|
||||
internalRunState.value = true;
|
||||
streamCallback!({
|
||||
type: "message_start",
|
||||
message: { role: "assistant", content: [] },
|
||||
});
|
||||
streamCallback!({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "partial" }] },
|
||||
});
|
||||
streamCallback!({
|
||||
type: "message_end",
|
||||
message: { role: "user", content: [{ type: "text", text: "hidden internal prompt" }] },
|
||||
});
|
||||
streamCallback!({
|
||||
type: "message_end",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "visible summary" }] },
|
||||
});
|
||||
|
||||
const first = await nextWithTimeout(iter);
|
||||
expect(first).not.toBe("timeout");
|
||||
if (first !== "timeout") {
|
||||
expect((first as { type: string }).type).toBe("message_start");
|
||||
expect((first as { message: { role: string } }).message.role).toBe("assistant");
|
||||
}
|
||||
|
||||
const second = await nextWithTimeout(iter);
|
||||
expect(second).not.toBe("timeout");
|
||||
if (second !== "timeout") {
|
||||
expect((second as { type: string }).type).toBe("message_update");
|
||||
expect((second as { message: { role: string } }).message.role).toBe("assistant");
|
||||
}
|
||||
|
||||
const third = await nextWithTimeout(iter);
|
||||
expect(third).not.toBe("timeout");
|
||||
if (third !== "timeout") {
|
||||
expect((third as { type: string }).type).toBe("message_end");
|
||||
expect((third as { message: { role: string } }).message.role).toBe("assistant");
|
||||
}
|
||||
|
||||
const fourth = await nextWithTimeout(iter);
|
||||
expect(fourth).toBe("timeout");
|
||||
|
||||
resolveRunInternal!({ text: "", thinking: undefined, error: undefined });
|
||||
await agent.waitForIdle();
|
||||
internalRunState.value = false;
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("persists assistant summary when persistResponse is true and result has text", async () => {
|
||||
runInternalMock.mockResolvedValueOnce({ text: "Summary of findings", thinking: undefined, error: undefined });
|
||||
const agent = new AsyncAgent();
|
||||
|
||||
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
|
||||
await agent.waitForIdle();
|
||||
|
||||
expect(persistAssistantSummaryMock).toHaveBeenCalledOnce();
|
||||
expect(persistAssistantSummaryMock).toHaveBeenCalledWith("Summary of findings");
|
||||
// flushSession called twice: once after runInternal, once after persistAssistantSummary
|
||||
expect(flushSessionMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("does not persist assistant summary when result text is NO_REPLY", async () => {
|
||||
runInternalMock.mockResolvedValueOnce({ text: "NO_REPLY", thinking: undefined, error: undefined });
|
||||
const agent = new AsyncAgent();
|
||||
|
||||
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
|
||||
await agent.waitForIdle();
|
||||
|
||||
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
|
||||
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("does not persist assistant summary when result text is empty", async () => {
|
||||
runInternalMock.mockResolvedValueOnce({ text: " ", thinking: undefined, error: undefined });
|
||||
const agent = new AsyncAgent();
|
||||
|
||||
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
|
||||
await agent.waitForIdle();
|
||||
|
||||
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
|
||||
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("does not persist assistant summary when persistResponse is not set", async () => {
|
||||
runInternalMock.mockResolvedValueOnce({ text: "Summary of findings", thinking: undefined, error: undefined });
|
||||
const agent = new AsyncAgent();
|
||||
|
||||
agent.writeInternal("announce findings", { forwardAssistant: true });
|
||||
await agent.waitForIdle();
|
||||
|
||||
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
|
||||
|
||||
agent.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -10,12 +10,21 @@ const devNull = { write: () => true } as unknown as NodeJS.WritableStream;
|
|||
/** Discriminated union of legacy Message, raw AgentEvent, and MulticaEvent */
|
||||
export type ChannelItem = Message | AgentEvent | MulticaEvent;
|
||||
|
||||
export interface WriteInternalOptions {
|
||||
/** Forward assistant message_end events to realtime stream during internal runs */
|
||||
forwardAssistant?: boolean | undefined;
|
||||
/** After internal run completes, persist the LLM's summary as a non-internal assistant message */
|
||||
persistResponse?: boolean | undefined;
|
||||
}
|
||||
|
||||
export class AsyncAgent {
|
||||
private readonly agent: Agent;
|
||||
private readonly channel = new Channel<ChannelItem>();
|
||||
private _closed = false;
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private pendingWrites = 0;
|
||||
private closeCallbacks: Array<() => void> = [];
|
||||
private forwardInternalAssistant = false;
|
||||
readonly sessionId: string;
|
||||
|
||||
constructor(options?: AgentOptions) {
|
||||
|
|
@ -25,8 +34,11 @@ export class AsyncAgent {
|
|||
});
|
||||
this.sessionId = this.agent.sessionId;
|
||||
|
||||
// Forward raw AgentEvent and MulticaEvent into the channel
|
||||
// Forward raw AgentEvent and MulticaEvent into the channel.
|
||||
// Suppress forwarding during internal runs to avoid leaking
|
||||
// orchestration messages to the frontend/real-time stream.
|
||||
this.agent.subscribeAll((event: AgentEvent | MulticaEvent) => {
|
||||
if (!this.shouldForwardEvent(event)) return;
|
||||
this.channel.send(event);
|
||||
});
|
||||
}
|
||||
|
|
@ -43,6 +55,7 @@ export class AsyncAgent {
|
|||
/** Enqueue an agent run, handling errors and session flush */
|
||||
private enqueue(runFn: () => ReturnType<Agent["run"]>): void {
|
||||
if (this._closed) throw new Error("Agent is closed");
|
||||
this.pendingWrites += 1;
|
||||
|
||||
this.queue = this.queue
|
||||
.then(async () => {
|
||||
|
|
@ -63,6 +76,47 @@ export class AsyncAgent {
|
|||
console.error(`[AsyncAgent] Agent run exception: ${message}`);
|
||||
this.channel.send({ id: uuidv7(), content: `[error] ${message}` });
|
||||
this.agent.emitMulticaEvent({ type: "agent_error", error: message });
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an internal message to agent (non-blocking, serialized queue).
|
||||
* Messages are persisted with `internal: true` and rolled back from
|
||||
* in-memory state. Events are suppressed from the real-time stream by default.
|
||||
*/
|
||||
writeInternal(content: string, options?: WriteInternalOptions): void {
|
||||
if (this._closed) throw new Error("Agent is closed");
|
||||
const forwardAssistant = options?.forwardAssistant === true;
|
||||
const persistResponse = options?.persistResponse === true;
|
||||
|
||||
this.queue = this.queue
|
||||
.then(async () => {
|
||||
if (this._closed) return;
|
||||
const prevForward = this.forwardInternalAssistant;
|
||||
this.forwardInternalAssistant = forwardAssistant;
|
||||
try {
|
||||
const result = await this.agent.runInternal(content);
|
||||
await this.agent.flushSession();
|
||||
if (result.error) {
|
||||
// Internal run errors are for diagnostics only; do not leak to user stream.
|
||||
console.error(`[AsyncAgent] Internal run error: ${result.error}`);
|
||||
}
|
||||
// Persist the LLM summary so it remains in parent context for future turns
|
||||
if (persistResponse && result.text?.trim() && result.text.trim() !== "NO_REPLY") {
|
||||
this.agent.persistAssistantSummary(result.text.trim());
|
||||
await this.agent.flushSession();
|
||||
}
|
||||
} finally {
|
||||
this.forwardInternalAssistant = prevForward;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Internal run exceptions are for diagnostics only; do not leak to user stream.
|
||||
console.error(`[AsyncAgent] Internal run failed: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +133,7 @@ export class AsyncAgent {
|
|||
subscribe(callback: (event: AgentEvent | MulticaEvent) => void): () => void {
|
||||
console.log(`[AsyncAgent] Adding subscriber for agent: ${this.sessionId}`);
|
||||
const unsubscribe = this.agent.subscribeAll((event) => {
|
||||
if (!this.shouldForwardEvent(event)) return;
|
||||
console.log(`[AsyncAgent] Event received: ${event.type}`);
|
||||
callback(event);
|
||||
});
|
||||
|
|
@ -93,6 +148,18 @@ export class AsyncAgent {
|
|||
return this.queue;
|
||||
}
|
||||
|
||||
private shouldForwardEvent(event: AgentEvent | MulticaEvent): boolean {
|
||||
if (!this.agent.isInternalRun) return true;
|
||||
if (!this.forwardInternalAssistant) return false;
|
||||
if (event.type !== "message_start" && event.type !== "message_update" && event.type !== "message_end") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maybeMessage = (event as { message?: unknown }).message;
|
||||
if (!maybeMessage || typeof maybeMessage !== "object") return false;
|
||||
return (maybeMessage as { role?: unknown }).role === "assistant";
|
||||
}
|
||||
|
||||
/** Register a callback to be invoked when the agent is closed */
|
||||
onClose(callback: () => void): void {
|
||||
if (this._closed) {
|
||||
|
|
@ -179,6 +246,34 @@ export class AsyncAgent {
|
|||
return this.agent.getProfileId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile directory path, if profile is enabled.
|
||||
*/
|
||||
getProfileDir(): string | undefined {
|
||||
return this.agent.getProfileDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heartbeat configuration from profile config.
|
||||
*/
|
||||
getHeartbeatConfig():
|
||||
| {
|
||||
enabled?: boolean | undefined;
|
||||
every?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
ackMaxChars?: number | undefined;
|
||||
}
|
||||
| undefined {
|
||||
return this.agent.getHeartbeatConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of queued/in-flight writes.
|
||||
*/
|
||||
getPendingWrites(): number {
|
||||
return this.pendingWrites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent display name from profile config.
|
||||
*/
|
||||
|
|
@ -235,12 +330,20 @@ export class AsyncAgent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get all messages from the current session.
|
||||
* Get all messages from the current session (in-memory state).
|
||||
*/
|
||||
getMessages(): AgentMessage[] {
|
||||
return this.agent.getMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages from session storage with filtering.
|
||||
* By default, internal messages are excluded.
|
||||
*/
|
||||
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
|
||||
return this.agent.loadSessionMessages(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current provider and model information.
|
||||
*/
|
||||
|
|
|
|||
466
src/agent/cli/commands/cron.ts
Normal file
466
src/agent/cli/commands/cron.ts
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
/**
|
||||
* Cron command - Manage scheduled tasks
|
||||
*
|
||||
* Usage:
|
||||
* multica cron status Show cron service status
|
||||
* multica cron list List all jobs
|
||||
* multica cron add <options> Add a new job
|
||||
* multica cron run <id> Run a job immediately
|
||||
* multica cron enable <id> Enable a job
|
||||
* multica cron disable <id> Disable a job
|
||||
* multica cron remove <id> Remove a job
|
||||
* multica cron logs <id> Show job run logs
|
||||
*/
|
||||
|
||||
import { cyan, yellow, green, dim, red, brightCyan } from "../colors.js";
|
||||
import {
|
||||
getCronService,
|
||||
formatSchedule,
|
||||
formatDuration,
|
||||
parseTimeInput,
|
||||
parseIntervalInput,
|
||||
isValidCronExpr,
|
||||
type CronSchedule,
|
||||
type CronJobInput,
|
||||
} from "../../../cron/index.js";
|
||||
|
||||
type Command = "status" | "list" | "add" | "run" | "enable" | "disable" | "remove" | "logs" | "help";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${brightCyan("Cron")} - Scheduled Task Management
|
||||
|
||||
${cyan("Usage:")} multica cron <command> [options]
|
||||
|
||||
${cyan("Commands:")}
|
||||
${yellow("status")} Show cron service status
|
||||
${yellow("list")} List all scheduled jobs
|
||||
${yellow("add")} [options] Create a new scheduled job
|
||||
${yellow("run")} <id> Run a job immediately
|
||||
${yellow("enable")} <id> Enable a disabled job
|
||||
${yellow("disable")} <id> Disable a job (keeps schedule)
|
||||
${yellow("remove")} <id> Delete a job
|
||||
${yellow("logs")} <id> Show run history for a job
|
||||
${yellow("help")} Show this help
|
||||
|
||||
${cyan("Add Options:")}
|
||||
${yellow("-n, --name")} <name> Job name (required)
|
||||
${yellow("--at")} <time> One-time at ISO timestamp or relative (e.g., "10m", "2h")
|
||||
${yellow("--every")} <interval> Repeat interval (e.g., "30m", "1h", "1d")
|
||||
${yellow("--cron")} <expr> Cron expression (5-field, e.g., "0 9 * * *")
|
||||
${yellow("--tz")} <timezone> Timezone for cron expression (e.g., "Asia/Shanghai")
|
||||
${yellow("--message")} <text> Message to inject or prompt for agent
|
||||
${yellow("--isolated")} Run in isolated session (default: main)
|
||||
${yellow("--delete-after-run")} Delete after one-time run completes
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# Show service status")}
|
||||
multica cron status
|
||||
|
||||
${dim("# 10 minutes from now (one-shot)")}
|
||||
multica cron add -n "Reminder" --at "10m" --message "Time to take a break!"
|
||||
|
||||
${dim("# Every day at 9am Beijing time")}
|
||||
multica cron add -n "Morning check" --cron "0 9 * * *" --tz "Asia/Shanghai" \\
|
||||
--message "Good morning! Check your tasks."
|
||||
|
||||
${dim("# Every 30 minutes")}
|
||||
multica cron add -n "Health check" --every "30m" --message "System health check"
|
||||
|
||||
${dim("# Run a job now")}
|
||||
multica cron run abc12345
|
||||
|
||||
${dim("# View job logs")}
|
||||
multica cron logs abc12345
|
||||
`);
|
||||
}
|
||||
|
||||
function cmdStatus() {
|
||||
const service = getCronService();
|
||||
const status = service.status();
|
||||
|
||||
console.log(`\n${brightCyan("Cron Service Status")}\n`);
|
||||
console.log(` ${cyan("Running:")} ${status.running ? green("Yes") : red("No")}`);
|
||||
console.log(` ${cyan("Enabled:")} ${status.enabled ? green("Yes") : red("No")}`);
|
||||
console.log(` ${cyan("Jobs:")} ${status.jobCount} total, ${status.enabledJobCount} enabled`);
|
||||
if (status.nextWakeAtMs) {
|
||||
const nextWake = new Date(status.nextWakeAtMs);
|
||||
const relativeMs = status.nextWakeAtMs - Date.now();
|
||||
console.log(` ${cyan("Next wake:")} ${nextWake.toLocaleString()} (in ${formatDuration(relativeMs)})`);
|
||||
} else {
|
||||
console.log(` ${cyan("Next wake:")} ${dim("none scheduled")}`);
|
||||
}
|
||||
console.log(` ${cyan("Store:")} ${dim(status.storePath)}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function cmdList(args: string[]) {
|
||||
const service = getCronService();
|
||||
const showEnabled = args.includes("--enabled");
|
||||
const showDisabled = args.includes("--disabled");
|
||||
|
||||
let filter: { enabled?: boolean } | undefined;
|
||||
if (showEnabled) filter = { enabled: true };
|
||||
else if (showDisabled) filter = { enabled: false };
|
||||
|
||||
const jobs = service.list(filter);
|
||||
|
||||
if (jobs.length === 0) {
|
||||
console.log("\nNo cron jobs found.");
|
||||
console.log(`${dim("Create one with:")} multica cron add -n "Name" --at "10m" --message "Hello"`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${brightCyan("Scheduled Jobs")}\n`);
|
||||
|
||||
for (const job of jobs) {
|
||||
const statusIcon = job.enabled ? green("✓") : red("✗");
|
||||
const shortId = job.id.slice(0, 8);
|
||||
|
||||
console.log(`${statusIcon} ${yellow(job.name)} ${dim(`(${shortId})`)}`);
|
||||
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
|
||||
console.log(` ${cyan("Target:")} ${job.sessionTarget}`);
|
||||
|
||||
if (job.state.nextRunAtMs) {
|
||||
const nextRun = new Date(job.state.nextRunAtMs);
|
||||
const relativeMs = job.state.nextRunAtMs - Date.now();
|
||||
if (relativeMs > 0) {
|
||||
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()} ${dim(`(in ${formatDuration(relativeMs)})`)}`);
|
||||
} else {
|
||||
console.log(` ${cyan("Next run:")} ${dim("pending execution")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (job.state.lastRunAtMs) {
|
||||
const lastRun = new Date(job.state.lastRunAtMs);
|
||||
const statusColor = job.state.lastStatus === "ok" ? green : job.state.lastStatus === "error" ? red : yellow;
|
||||
console.log(` ${cyan("Last run:")} ${lastRun.toLocaleString()} ${statusColor(`[${job.state.lastStatus}]`)} ${dim(`(${formatDuration(job.state.lastDurationMs ?? 0)})`)}`);
|
||||
if (job.state.lastError) {
|
||||
console.log(` ${red("Error:")} ${job.state.lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log(dim(`Total: ${jobs.length} job(s)`));
|
||||
}
|
||||
|
||||
function cmdAdd(args: string[]) {
|
||||
const service = getCronService();
|
||||
|
||||
// Parse arguments
|
||||
let name: string | undefined;
|
||||
let at: string | undefined;
|
||||
let every: string | undefined;
|
||||
let cronExpr: string | undefined;
|
||||
let tz: string | undefined;
|
||||
let message: string | undefined;
|
||||
let isolated = false;
|
||||
let deleteAfterRun = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
switch (arg) {
|
||||
case "-n":
|
||||
case "--name":
|
||||
name = args[++i];
|
||||
break;
|
||||
case "--at":
|
||||
at = args[++i];
|
||||
break;
|
||||
case "--every":
|
||||
every = args[++i];
|
||||
break;
|
||||
case "--cron":
|
||||
cronExpr = args[++i];
|
||||
break;
|
||||
case "--tz":
|
||||
tz = args[++i];
|
||||
break;
|
||||
case "--message":
|
||||
message = args[++i];
|
||||
break;
|
||||
case "--isolated":
|
||||
isolated = true;
|
||||
break;
|
||||
case "--delete-after-run":
|
||||
deleteAfterRun = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate
|
||||
if (!name) {
|
||||
console.error(`${red("Error:")} --name is required`);
|
||||
console.error(`${dim("Usage:")} multica cron add -n "Job name" --at "10m" --message "Hello"`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
console.error(`${red("Error:")} --message is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse schedule
|
||||
let schedule: CronSchedule;
|
||||
if (at) {
|
||||
const atMs = parseTimeInput(at);
|
||||
if (!atMs) {
|
||||
console.error(`${red("Error:")} Invalid time format: ${at}`);
|
||||
console.error(`${dim("Examples:")} "10m", "2h", "2024-12-31T23:59:00Z"`);
|
||||
process.exit(1);
|
||||
}
|
||||
schedule = { kind: "at", atMs };
|
||||
} else if (every) {
|
||||
const everyMs = parseIntervalInput(every);
|
||||
if (!everyMs) {
|
||||
console.error(`${red("Error:")} Invalid interval format: ${every}`);
|
||||
console.error(`${dim("Examples:")} "30s", "5m", "2h", "1d"`);
|
||||
process.exit(1);
|
||||
}
|
||||
schedule = { kind: "every", everyMs };
|
||||
} else if (cronExpr) {
|
||||
if (!isValidCronExpr(cronExpr, tz)) {
|
||||
console.error(`${red("Error:")} Invalid cron expression: ${cronExpr}`);
|
||||
console.error(`${dim("Format:")} "minute hour day month weekday" (e.g., "0 9 * * *")`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Only include tz if it's defined (exactOptionalPropertyTypes)
|
||||
schedule = tz ? { kind: "cron", expr: cronExpr, tz } : { kind: "cron", expr: cronExpr };
|
||||
} else {
|
||||
console.error(`${red("Error:")} Must specify --at, --every, or --cron`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create job
|
||||
const input: CronJobInput = {
|
||||
name,
|
||||
enabled: true,
|
||||
deleteAfterRun,
|
||||
schedule,
|
||||
sessionTarget: isolated ? "isolated" : "main",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "system-event",
|
||||
text: message,
|
||||
},
|
||||
};
|
||||
|
||||
const job = service.add(input);
|
||||
|
||||
console.log(`\n${green("Created job:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}`);
|
||||
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
|
||||
if (job.state.nextRunAtMs) {
|
||||
const nextRun = new Date(job.state.nextRunAtMs);
|
||||
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
async function cmdRun(args: string[]) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
const force = args.includes("--force");
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
console.error(`${dim("Usage:")} multica cron run <id> [--force]`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
console.error("Please provide a more specific ID.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
console.log(`Running job: ${job.name} (${job.id.slice(0, 8)})...`);
|
||||
|
||||
const result = await service.run(job.id, force);
|
||||
if (result.ok) {
|
||||
console.log(`${green("Success:")} Job executed`);
|
||||
} else {
|
||||
console.error(`${red("Error:")} ${result.reason}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdEnableDisable(args: string[], enabled: boolean) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
service.update(job.id, { enabled });
|
||||
|
||||
const action = enabled ? "Enabled" : "Disabled";
|
||||
console.log(`${green(action + ":")} ${job.name} (${job.id.slice(0, 8)})`);
|
||||
}
|
||||
|
||||
function cmdRemove(args: string[]) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
service.remove(job.id);
|
||||
console.log(`${green("Removed:")} ${job.name} (${job.id.slice(0, 8)})`);
|
||||
}
|
||||
|
||||
function cmdLogs(args: string[]) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
const limitArg = args.indexOf("--limit");
|
||||
const limitStr = limitArg !== -1 ? args[limitArg + 1] : undefined;
|
||||
const limit = limitStr ? parseInt(limitStr, 10) : 20;
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
console.error(`${dim("Usage:")} multica cron logs <id> [--limit N]`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
const logs = service.getRunLogs(job.id, limit);
|
||||
|
||||
console.log(`\n${brightCyan("Run Logs:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}\n`);
|
||||
|
||||
if (logs.length === 0) {
|
||||
console.log(dim("No run logs found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const log of logs) {
|
||||
const timestamp = new Date(log.ts).toLocaleString();
|
||||
const statusColor = log.status === "ok" ? green : log.status === "error" ? red : yellow;
|
||||
const duration = log.durationMs ? formatDuration(log.durationMs) : "-";
|
||||
|
||||
console.log(` ${dim(timestamp)} ${statusColor(`[${log.status}]`)} ${dim(`(${duration})`)}`);
|
||||
if (log.error) {
|
||||
console.log(` ${red("Error:")} ${log.error}`);
|
||||
}
|
||||
if (log.summary) {
|
||||
console.log(` ${dim(log.summary)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${dim(`Showing ${logs.length} most recent entries`)}`);
|
||||
}
|
||||
|
||||
export async function cronCommand(args: string[]): Promise<void> {
|
||||
const command = (args[0] || "help") as Command;
|
||||
const restArgs = args.slice(1);
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure service is started
|
||||
const service = getCronService();
|
||||
await service.start();
|
||||
|
||||
switch (command) {
|
||||
case "status":
|
||||
cmdStatus();
|
||||
break;
|
||||
case "list":
|
||||
cmdList(restArgs);
|
||||
break;
|
||||
case "add":
|
||||
cmdAdd(restArgs);
|
||||
break;
|
||||
case "run":
|
||||
await cmdRun(restArgs);
|
||||
break;
|
||||
case "enable":
|
||||
cmdEnableDisable(restArgs, true);
|
||||
break;
|
||||
case "disable":
|
||||
cmdEnableDisable(restArgs, false);
|
||||
break;
|
||||
case "remove":
|
||||
cmdRemove(restArgs);
|
||||
break;
|
||||
case "logs":
|
||||
cmdLogs(restArgs);
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printHelp();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ ${cyan("Usage:")} multica session <command> [options]
|
|||
|
||||
${cyan("Commands:")}
|
||||
${yellow("list")} List all sessions
|
||||
${yellow("show")} <id> Show session details
|
||||
${yellow("show")} <id> Show session details (use --show-internal to include internal messages)
|
||||
${yellow("delete")} <id> Delete a session
|
||||
${yellow("help")} Show this help
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ function cmdList() {
|
|||
console.log(`${dim("Resume with:")} multica --session <id>`);
|
||||
}
|
||||
|
||||
function cmdShow(sessionId: string | undefined) {
|
||||
function cmdShow(sessionId: string | undefined, showInternal = false) {
|
||||
if (!sessionId) {
|
||||
console.error("Error: Session ID is required");
|
||||
console.error("Usage: multica session show <id>");
|
||||
|
|
@ -160,14 +160,25 @@ function cmdShow(sessionId: string | undefined) {
|
|||
console.log(cyan("─".repeat(60)));
|
||||
console.log("");
|
||||
|
||||
// Parse and display messages
|
||||
// Parse and display messages as SessionEntry objects
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Only display message entries
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
// Skip internal messages unless --show-internal
|
||||
if (entry.internal && !showInternal) continue;
|
||||
|
||||
const msg = entry.message;
|
||||
if (!msg) continue;
|
||||
|
||||
const role = msg.role || "unknown";
|
||||
const roleColor = role === "user" ? green : role === "assistant" ? cyan : dim;
|
||||
const internalTag = entry.internal ? dim(" [internal]") : "";
|
||||
|
||||
console.log(`${roleColor(`[${role}]`)}`);
|
||||
console.log(`${roleColor(`[${role}]`)}${internalTag}`);
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
// Truncate long content
|
||||
|
|
@ -238,6 +249,7 @@ function cmdDelete(sessionId: string | undefined) {
|
|||
export async function sessionCommand(args: string[]): Promise<void> {
|
||||
const command = (args[0] || "help") as Command;
|
||||
const arg1 = args[1];
|
||||
const showInternal = args.includes("--show-internal");
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printHelp();
|
||||
|
|
@ -249,7 +261,7 @@ export async function sessionCommand(args: string[]): Promise<void> {
|
|||
cmdList();
|
||||
break;
|
||||
case "show":
|
||||
cmdShow(arg1);
|
||||
cmdShow(arg1, showInternal);
|
||||
break;
|
||||
case "delete":
|
||||
cmdDelete(arg1);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
* multica skills <cmd> Skills management
|
||||
* multica tools <cmd> Tool policy inspection
|
||||
* multica credentials <cmd> Credentials management
|
||||
* multica cron <cmd> Scheduled task management
|
||||
* multica dev [service] Development servers
|
||||
* multica help Show help
|
||||
*/
|
||||
|
|
@ -29,6 +30,7 @@ const subcommands: Record<string, () => Promise<SubcommandHandler>> = {
|
|||
tools: async () => (await import("./commands/tools.js")).toolsCommand,
|
||||
credentials: async () => (await import("./commands/credentials.js")).credentialsCommand,
|
||||
dev: async () => (await import("./commands/dev.js")).devCommand,
|
||||
cron: async () => (await import("./commands/cron.js")).cronCommand,
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
|
|
@ -44,6 +46,7 @@ ${cyan("Usage:")}
|
|||
${yellow("multica skills")} <command> Manage skills
|
||||
${yellow("multica tools")} <command> Inspect tool policies
|
||||
${yellow("multica credentials")} <command> Manage credentials
|
||||
${yellow("multica cron")} <command> Manage scheduled tasks
|
||||
${yellow("multica dev")} [service] Start development servers
|
||||
${yellow("multica help")} Show this help
|
||||
|
||||
|
|
@ -85,6 +88,16 @@ ${cyan("Commands:")}
|
|||
show Show credential paths
|
||||
edit Open credentials in editor
|
||||
|
||||
${green("cron")}
|
||||
status Show cron service status
|
||||
list List all scheduled jobs
|
||||
add [options] Create a new scheduled job
|
||||
run <id> Run a job immediately
|
||||
enable <id> Enable a job
|
||||
disable <id> Disable a job
|
||||
remove <id> Delete a job
|
||||
logs <id> Show job run logs
|
||||
|
||||
${green("dev")}
|
||||
${dim("(default)")} Start all services (gateway + console + web)
|
||||
gateway Start gateway only (:3000)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export function createAgentProfile(
|
|||
profile.user = DEFAULT_TEMPLATES.user;
|
||||
profile.workspace = DEFAULT_TEMPLATES.workspace;
|
||||
profile.memory = DEFAULT_TEMPLATES.memory;
|
||||
profile.heartbeat = DEFAULT_TEMPLATES.heartbeat;
|
||||
|
||||
// 保存到文件
|
||||
saveProfile(profile, { baseDir });
|
||||
|
|
@ -150,6 +151,7 @@ export class ProfileManager {
|
|||
user: profile.user,
|
||||
workspace: profile.workspace,
|
||||
memory: profile.memory,
|
||||
heartbeat: profile.heartbeat,
|
||||
config: profile.config,
|
||||
},
|
||||
profileDir: this.getProfileDir(),
|
||||
|
|
@ -168,6 +170,19 @@ export class ProfileManager {
|
|||
return profile?.config;
|
||||
}
|
||||
|
||||
/** Get heartbeat configuration from profile config */
|
||||
getHeartbeatConfig():
|
||||
| {
|
||||
enabled?: boolean | undefined;
|
||||
every?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
ackMaxChars?: number | undefined;
|
||||
}
|
||||
| undefined {
|
||||
const profile = this.getProfile();
|
||||
return profile?.config?.heartbeat;
|
||||
}
|
||||
|
||||
/** 更新 tools 配置 */
|
||||
updateToolsConfig(toolsConfig: ToolsConfig): void {
|
||||
const profile = this.getOrCreateProfile(false);
|
||||
|
|
|
|||
|
|
@ -95,13 +95,14 @@ export function loadProfile(profileId: string, options?: StorageOptions): AgentP
|
|||
user: readProfileFile(profileId, PROFILE_FILES.user, options),
|
||||
workspace: readProfileFile(profileId, PROFILE_FILES.workspace, options),
|
||||
memory: readProfileFile(profileId, PROFILE_FILES.memory, options),
|
||||
heartbeat: readProfileFile(profileId, PROFILE_FILES.heartbeat, options),
|
||||
config: readProfileConfig(profileId, options),
|
||||
};
|
||||
}
|
||||
|
||||
/** 保存 AgentProfile(只写入非空字段) */
|
||||
export function saveProfile(profile: AgentProfile, options?: StorageOptions): void {
|
||||
const { id, soul, user, workspace, memory, config } = profile;
|
||||
const { id, soul, user, workspace, memory, heartbeat, config } = profile;
|
||||
|
||||
if (soul !== undefined) {
|
||||
writeProfileFile(id, PROFILE_FILES.soul, soul, options);
|
||||
|
|
@ -115,6 +116,9 @@ export function saveProfile(profile: AgentProfile, options?: StorageOptions): vo
|
|||
if (memory !== undefined) {
|
||||
writeProfileFile(id, PROFILE_FILES.memory, memory, options);
|
||||
}
|
||||
if (heartbeat !== undefined) {
|
||||
writeProfileFile(id, PROFILE_FILES.heartbeat, heartbeat, options);
|
||||
}
|
||||
if (config !== undefined) {
|
||||
writeProfileConfig(id, config, options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ Your profile directory contains these files (use \`edit\` or \`write\` to update
|
|||
| \`user.md\` | About your human | As you learn about them |
|
||||
| \`workspace.md\` | This file — your rules | When you discover better conventions |
|
||||
| \`memory.md\` | Long-term knowledge | Regularly — capture what matters |
|
||||
| \`heartbeat.md\` | Background check instructions | When heartbeat behavior should change |
|
||||
|
||||
## Every Session
|
||||
|
||||
|
|
@ -89,6 +90,7 @@ You wake up fresh each session. These files are your continuity:
|
|||
|
||||
- **Long-term:** \`MEMORY.md\` — your curated memories, lessons learned
|
||||
- **Daily notes:** \`memory/YYYY-MM-DD.md\` — raw logs of what happened (optional)
|
||||
- **Heartbeat:** \`heartbeat.md\` — periodic check loop instructions
|
||||
|
||||
Capture what matters. Decisions, context, things to remember.
|
||||
|
||||
|
|
@ -101,6 +103,7 @@ Capture what matters. Decisions, context, things to remember.
|
|||
- \`memory.md\` — Your learnings: decisions made, lessons learned, important context
|
||||
- \`workspace.md\` — Your rules: conventions, workflows, how you should operate
|
||||
- \`soul.md\` — Your identity: only change if user wants to reshape who you are
|
||||
- \`heartbeat.md\` — Periodic background checks and alert rules
|
||||
|
||||
**Rules:**
|
||||
- **DO NOT** say "I'll remember that" without ACTUALLY calling \`edit\` or \`write\` on a file
|
||||
|
|
@ -148,5 +151,11 @@ _(Persistent knowledge will be stored here. Update this as you learn.)_
|
|||
## Lessons Learned
|
||||
|
||||
## Important Context
|
||||
`,
|
||||
|
||||
heartbeat: `# heartbeat.md
|
||||
|
||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
`,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export const PROFILE_FILES = {
|
|||
user: "user.md",
|
||||
workspace: "workspace.md",
|
||||
memory: "memory.md",
|
||||
heartbeat: "heartbeat.md",
|
||||
config: "config.json",
|
||||
} as const;
|
||||
|
||||
|
|
@ -42,6 +43,17 @@ export interface ProfileConfig {
|
|||
reasoningMode?: "off" | "on" | "stream" | undefined;
|
||||
/** Exec approval configuration (security level, ask mode, allowlist) */
|
||||
execApproval?: ExecApprovalConfig | undefined;
|
||||
/** Heartbeat configuration */
|
||||
heartbeat?: {
|
||||
/** Global heartbeat enable switch */
|
||||
enabled?: boolean | undefined;
|
||||
/** Interval, e.g. "30m", "1h" */
|
||||
every?: string | undefined;
|
||||
/** Optional prompt override */
|
||||
prompt?: string | undefined;
|
||||
/** Max chars after HEARTBEAT_OK to still treat as ack */
|
||||
ackMaxChars?: number | undefined;
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
/** Agent Profile configuration */
|
||||
|
|
@ -56,6 +68,8 @@ export interface AgentProfile {
|
|||
workspace?: string | undefined;
|
||||
/** Persistent memory - long-term knowledge base */
|
||||
memory?: string | undefined;
|
||||
/** Periodic heartbeat instructions */
|
||||
heartbeat?: string | undefined;
|
||||
/** Profile configuration (from config.json) */
|
||||
config?: ProfileConfig | undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js";
|
||||
import type { MulticaEvent } from "./events.js";
|
||||
|
|
@ -85,6 +84,10 @@ export class Agent {
|
|||
private readonly stderr: NodeJS.WritableStream;
|
||||
private initialized = false;
|
||||
|
||||
// Internal run state
|
||||
private _internalRun = false;
|
||||
private _runMutex: Promise<void> = Promise.resolve();
|
||||
|
||||
// MulticaEvent subscribers (parallel to PiAgentCore's subscriber list)
|
||||
// Typed as AgentEvent | MulticaEvent to match subscribeAll() callback signature
|
||||
private multicaListeners: Array<(event: AgentEvent | MulticaEvent) => void> = [];
|
||||
|
|
@ -353,7 +356,49 @@ export class Agent {
|
|||
}
|
||||
}
|
||||
|
||||
async run(prompt: string, images?: ImageContent[]): Promise<AgentRunResult> {
|
||||
async run(prompt: string): Promise<AgentRunResult> {
|
||||
// Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages
|
||||
return this.withRunMutex(() => this._run(prompt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a prompt as an internal turn.
|
||||
* Messages are persisted with `internal: true` and rolled back from
|
||||
* in-memory state after the turn completes, so they do not pollute
|
||||
* the main conversation context.
|
||||
*/
|
||||
async runInternal(prompt: string): Promise<AgentRunResult> {
|
||||
return this.withRunMutex(async () => {
|
||||
const messageCountBefore = this.agent.state.messages.length;
|
||||
this._internalRun = true;
|
||||
try {
|
||||
const result = await this._run(prompt);
|
||||
return result;
|
||||
} finally {
|
||||
this._internalRun = false;
|
||||
// Roll back internal messages from in-memory state
|
||||
const current = this.agent.state.messages;
|
||||
if (current.length > messageCountBefore) {
|
||||
this.agent.replaceMessages(current.slice(0, messageCountBefore));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async withRunMutex<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// Chain on the mutex so only one run executes at a time
|
||||
const prev = this._runMutex;
|
||||
let resolve: () => void;
|
||||
this._runMutex = new Promise<void>((r) => { resolve = r; });
|
||||
await prev;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
resolve!();
|
||||
}
|
||||
}
|
||||
|
||||
private async _run(prompt: string): Promise<AgentRunResult> {
|
||||
await this.ensureInitialized();
|
||||
this.output.state.lastAssistantText = "";
|
||||
|
||||
|
|
@ -363,7 +408,7 @@ export class Agent {
|
|||
// Loop to exhaust all candidate profiles on rotatable errors
|
||||
while (true) {
|
||||
try {
|
||||
await this.agent.prompt(prompt, images);
|
||||
await this.agent.prompt(prompt);
|
||||
break; // success — exit loop
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
|
@ -443,8 +488,10 @@ export class Agent {
|
|||
private handleSessionEvent(event: AgentEvent) {
|
||||
if (event.type === "message_end") {
|
||||
const message = event.message as AgentMessage;
|
||||
this.session.saveMessage(message);
|
||||
if (message.role === "assistant") {
|
||||
this.session.saveMessage(message, this._internalRun ? { internal: true } : undefined);
|
||||
// Skip compaction during internal runs — internal messages will be
|
||||
// rolled back from memory afterwards, so compacting now would be incorrect.
|
||||
if (message.role === "assistant" && !this._internalRun) {
|
||||
void this.maybeCompact();
|
||||
}
|
||||
}
|
||||
|
|
@ -511,6 +558,40 @@ export class Agent {
|
|||
return this.agent.state.tools?.map(t => t.name) ?? [];
|
||||
}
|
||||
|
||||
/** Whether the agent is currently executing an internal run */
|
||||
get isInternalRun(): boolean {
|
||||
return this._internalRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a synthetic assistant message into both in-memory state and session JSONL.
|
||||
* Used after an internal run to keep the LLM summary visible in future turns
|
||||
* while the internal prompt stays hidden.
|
||||
*/
|
||||
persistAssistantSummary(text: string): void {
|
||||
const model = this.agent.state.model;
|
||||
const message = {
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text }],
|
||||
api: model?.api ?? "openai-completions",
|
||||
provider: model?.provider ?? "internal",
|
||||
model: model?.id ?? "unknown",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop" as const,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.agent.appendMessage(message);
|
||||
this.session.saveMessage(message);
|
||||
}
|
||||
|
||||
/** Ensure session messages are loaded from disk (idempotent) */
|
||||
async ensureInitialized(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
|
@ -522,11 +603,19 @@ export class Agent {
|
|||
this.initialized = true;
|
||||
}
|
||||
|
||||
/** Get all messages from the current session */
|
||||
/** Get all messages from the current session (in-memory state) */
|
||||
getMessages(): AgentMessage[] {
|
||||
return this.agent.state.messages.slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages from session storage with filtering.
|
||||
* By default, internal messages are excluded.
|
||||
*/
|
||||
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
|
||||
return this.session.loadMessages(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all skills with their eligibility status.
|
||||
* Returns empty array if skills are disabled.
|
||||
|
|
@ -596,6 +685,27 @@ export class Agent {
|
|||
return this.profile?.getProfile()?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile directory path, if profile is enabled.
|
||||
*/
|
||||
getProfileDir(): string | undefined {
|
||||
return this.profile?.getProfileDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heartbeat configuration from profile config.
|
||||
*/
|
||||
getHeartbeatConfig():
|
||||
| {
|
||||
enabled?: boolean | undefined;
|
||||
every?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
ackMaxChars?: number | undefined;
|
||||
}
|
||||
| undefined {
|
||||
return this.profile?.getHeartbeatConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent display name from profile config.
|
||||
*/
|
||||
|
|
@ -771,6 +881,7 @@ export class Agent {
|
|||
user: profile.user,
|
||||
workspace: profile.workspace,
|
||||
memory: profile.memory,
|
||||
heartbeat: profile.heartbeat,
|
||||
config: profile.config,
|
||||
},
|
||||
profileDir: this.profile!.getProfileDir(),
|
||||
|
|
|
|||
|
|
@ -167,11 +167,15 @@ export class SessionManager {
|
|||
return repairSessionFileIfNeeded({ sessionFile: filePath, warn });
|
||||
}
|
||||
|
||||
loadMessages(): AgentMessage[] {
|
||||
loadMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
|
||||
const entries = this.loadEntries();
|
||||
let messages = entries
|
||||
.filter((entry) => entry.type === "message")
|
||||
.map((entry) => entry.message);
|
||||
.filter((entry) => {
|
||||
if (entry.type !== "message") return false;
|
||||
if (!options?.includeInternal && entry.internal) return false;
|
||||
return true;
|
||||
})
|
||||
.map((entry) => (entry as { type: "message"; message: AgentMessage }).message);
|
||||
messages = sanitizeToolCallInputs(messages);
|
||||
messages = sanitizeToolUseResultPairing(messages);
|
||||
return messages;
|
||||
|
|
@ -203,11 +207,16 @@ export class SessionManager {
|
|||
);
|
||||
}
|
||||
|
||||
saveMessage(message: AgentMessage) {
|
||||
saveMessage(message: AgentMessage, options?: { internal?: boolean }) {
|
||||
void this.enqueue(() =>
|
||||
appendEntry(
|
||||
this.sessionId,
|
||||
{ type: "message", message, timestamp: Date.now() },
|
||||
{
|
||||
type: "message",
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
...(options?.internal ? { internal: true } : {}),
|
||||
},
|
||||
{ baseDir: this.baseDir },
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export type SessionMeta = {
|
|||
};
|
||||
|
||||
export type SessionEntry =
|
||||
| { type: "message"; message: AgentMessage; timestamp: number }
|
||||
| { type: "message"; message: AgentMessage; timestamp: number; internal?: boolean }
|
||||
| { type: "meta"; meta: SessionMeta; timestamp: number }
|
||||
| {
|
||||
type: "compaction";
|
||||
|
|
|
|||
67
src/agent/subagent/announce-findings.test.ts
Normal file
67
src/agent/subagent/announce-findings.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const readEntriesMock = vi.fn();
|
||||
|
||||
vi.mock("../session/storage.js", () => ({
|
||||
readEntries: (sessionId: string) => readEntriesMock(sessionId),
|
||||
}));
|
||||
|
||||
import { readLatestAssistantReply } from "./announce.js";
|
||||
|
||||
describe("readLatestAssistantReply", () => {
|
||||
beforeEach(() => {
|
||||
readEntriesMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns the latest non-empty assistant text when the last assistant message is tool-only", () => {
|
||||
readEntriesMock.mockReturnValue([
|
||||
{
|
||||
type: "message",
|
||||
timestamp: 1,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "南京天气:晴,12°C。" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: 2,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "weather", arguments: { city: "Nanjing" } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = readLatestAssistantReply("child-session");
|
||||
expect(result).toBe("南京天气:晴,12°C。");
|
||||
});
|
||||
|
||||
it("falls back to latest toolResult text when no assistant text exists", () => {
|
||||
readEntriesMock.mockReturnValue([
|
||||
{
|
||||
type: "message",
|
||||
timestamp: 1,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "tool-2", name: "weather", arguments: { city: "Nanjing" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: 2,
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId: "tool-2",
|
||||
toolName: "weather",
|
||||
content: [{ type: "text", text: "{\"city\":\"Nanjing\",\"tempC\":12,\"condition\":\"Sunny\"}" }],
|
||||
isError: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = readLatestAssistantReply("child-session");
|
||||
expect(result).toContain("\"city\":\"Nanjing\"");
|
||||
expect(result).toContain("\"condition\":\"Sunny\"");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildSubagentSystemPrompt, formatAnnouncementMessage } from "./announce.js";
|
||||
import { buildSubagentSystemPrompt, formatAnnouncementMessage, formatCoalescedAnnouncementMessage } from "./announce.js";
|
||||
import type { FormatAnnouncementParams } from "./announce.js";
|
||||
import type { SubagentRunRecord } from "./types.js";
|
||||
|
||||
describe("buildSubagentSystemPrompt", () => {
|
||||
it("includes task and session context", () => {
|
||||
|
|
@ -126,3 +127,128 @@ describe("formatAnnouncementMessage", () => {
|
|||
expect(msg).toContain("NO_REPLY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCoalescedAnnouncementMessage", () => {
|
||||
function makeRecord(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
|
||||
return {
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Default task",
|
||||
cleanup: "delete",
|
||||
createdAt: 1000000,
|
||||
startedAt: 1000000,
|
||||
endedAt: 1030000,
|
||||
outcome: { status: "ok" },
|
||||
findings: "Some findings",
|
||||
findingsCaptured: true,
|
||||
announced: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("delegates to formatAnnouncementMessage for a single record", () => {
|
||||
const record = makeRecord({ label: "Code Analysis" });
|
||||
const coalesced = formatCoalescedAnnouncementMessage([record]);
|
||||
const direct = formatAnnouncementMessage({
|
||||
runId: record.runId,
|
||||
childSessionId: record.childSessionId,
|
||||
requesterSessionId: record.requesterSessionId,
|
||||
task: record.task,
|
||||
label: record.label,
|
||||
cleanup: record.cleanup,
|
||||
outcome: record.outcome,
|
||||
startedAt: record.startedAt,
|
||||
endedAt: record.endedAt,
|
||||
findings: record.findings,
|
||||
});
|
||||
|
||||
expect(coalesced).toBe(direct);
|
||||
});
|
||||
|
||||
it("formats multiple records with all task findings and stats", () => {
|
||||
const records = [
|
||||
makeRecord({
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
label: "Task A",
|
||||
findings: "Found issue A",
|
||||
startedAt: 1000000,
|
||||
endedAt: 1030000,
|
||||
}),
|
||||
makeRecord({
|
||||
runId: "run-2",
|
||||
childSessionId: "child-2",
|
||||
label: "Task B",
|
||||
findings: "Found issue B",
|
||||
startedAt: 1000000,
|
||||
endedAt: 1045000, // 45 seconds
|
||||
}),
|
||||
];
|
||||
|
||||
const msg = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
expect(msg).toContain("All 2 background tasks have completed");
|
||||
expect(msg).toContain('Task 1: "Task A"');
|
||||
expect(msg).toContain("Found issue A");
|
||||
expect(msg).toContain('Task 2: "Task B"');
|
||||
expect(msg).toContain("Found issue B");
|
||||
expect(msg).toContain("Total wall time: 45s");
|
||||
expect(msg).toContain("2 succeeded, 0 failed");
|
||||
});
|
||||
|
||||
it("reports mixed outcomes correctly", () => {
|
||||
const records = [
|
||||
makeRecord({ runId: "run-1", label: "OK Task", outcome: { status: "ok" } }),
|
||||
makeRecord({ runId: "run-2", label: "Failed Task", outcome: { status: "error", error: "crash" } }),
|
||||
makeRecord({ runId: "run-3", label: "Timeout Task", outcome: { status: "timeout" } }),
|
||||
];
|
||||
|
||||
const msg = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
expect(msg).toContain("completed successfully");
|
||||
expect(msg).toContain("failed: crash");
|
||||
expect(msg).toContain("timed out");
|
||||
expect(msg).toContain("1 succeeded, 2 failed");
|
||||
});
|
||||
|
||||
it("shows (no output) for missing findings", () => {
|
||||
const records = [
|
||||
makeRecord({ runId: "run-1", findings: undefined }),
|
||||
makeRecord({ runId: "run-2", findings: "Has output" }),
|
||||
];
|
||||
|
||||
const msg = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
expect(msg).toContain("(no output)");
|
||||
expect(msg).toContain("Has output");
|
||||
});
|
||||
|
||||
it("includes combined summary instruction for multi-record", () => {
|
||||
const records = [
|
||||
makeRecord({ runId: "run-1" }),
|
||||
makeRecord({ runId: "run-2" }),
|
||||
];
|
||||
|
||||
const msg = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
expect(msg).toContain("MUST include findings from every task item above");
|
||||
expect(msg).toContain("NO_REPLY");
|
||||
});
|
||||
|
||||
it("includes raw findings for every task in coalesced payload", () => {
|
||||
const records = [
|
||||
makeRecord({ runId: "run-1", label: "南京天气", findings: "南京:晴,12°C" }),
|
||||
makeRecord({ runId: "run-2", label: "上海天气", findings: "上海:多云,9°C" }),
|
||||
];
|
||||
|
||||
const msg = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
expect(msg).toContain("Raw findings from each task (MUST cover all items):");
|
||||
expect(msg).toContain("[1] 南京天气:");
|
||||
expect(msg).toContain("南京:晴,12°C");
|
||||
expect(msg).toContain("[2] 上海天气:");
|
||||
expect(msg).toContain("上海:多云,9°C");
|
||||
expect(msg).toContain("MUST include findings from every task item above");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { buildSystemPrompt } from "../system-prompt/index.js";
|
|||
import type {
|
||||
SubagentAnnounceParams,
|
||||
SubagentRunOutcome,
|
||||
SubagentRunRecord,
|
||||
SubagentSystemPromptParams,
|
||||
} from "./types.js";
|
||||
|
||||
|
|
@ -38,19 +39,29 @@ export function buildSubagentSystemPrompt(params: SubagentSystemPromptParams): s
|
|||
*/
|
||||
export function readLatestAssistantReply(sessionId: string): string | undefined {
|
||||
const entries = readEntries(sessionId);
|
||||
let latestToolResultText: string | undefined;
|
||||
|
||||
// Walk backwards to find last assistant message
|
||||
// Walk backwards to find the last non-empty assistant reply.
|
||||
// If no assistant text exists (e.g. run ended after tool execution),
|
||||
// fall back to the latest non-empty toolResult content.
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i]!;
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
const message = entry.message;
|
||||
if (message.role !== "assistant") continue;
|
||||
if (message.role === "assistant") {
|
||||
const text = extractAssistantText(message);
|
||||
if (text) return text;
|
||||
continue;
|
||||
}
|
||||
|
||||
return extractAssistantText(message);
|
||||
if (message.role === "toolResult" && !latestToolResultText) {
|
||||
const text = extractToolResultText(message);
|
||||
if (text) latestToolResultText = text;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return latestToolResultText;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,7 +69,17 @@ export function readLatestAssistantReply(sessionId: string): string | undefined
|
|||
* AgentMessage.content for assistant is (TextContent | ThinkingContent | ToolCall)[].
|
||||
*/
|
||||
function extractAssistantText(message: { role: string; content: unknown }): string {
|
||||
const content = message.content;
|
||||
return extractTextLikeContent(message.content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from a toolResult message.
|
||||
*/
|
||||
function extractToolResultText(message: { role: string; content: unknown }): string {
|
||||
return extractTextLikeContent(message.content);
|
||||
}
|
||||
|
||||
function extractTextLikeContent(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return sanitizeText(content);
|
||||
}
|
||||
|
|
@ -67,8 +88,9 @@ function extractAssistantText(message: { role: string; content: unknown }): stri
|
|||
|
||||
const textParts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && "type" in block && block.type === "text" && "text" in block) {
|
||||
textParts.push(String(block.text));
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if ("text" in block) {
|
||||
textParts.push(String((block as { text: unknown }).text));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,11 +189,126 @@ export function formatAnnouncementMessage(params: FormatAnnouncementParams): str
|
|||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a coalesced announcement message from multiple completed subagent runs.
|
||||
* When only one record is provided, delegates to formatAnnouncementMessage.
|
||||
*/
|
||||
export function formatCoalescedAnnouncementMessage(
|
||||
records: SubagentRunRecord[],
|
||||
): string {
|
||||
// Single record: delegate to existing format for backward-compatible behavior
|
||||
if (records.length === 1) {
|
||||
const r = records[0]!;
|
||||
return formatAnnouncementMessage({
|
||||
runId: r.runId,
|
||||
childSessionId: r.childSessionId,
|
||||
requesterSessionId: r.requesterSessionId,
|
||||
task: r.task,
|
||||
label: r.label,
|
||||
cleanup: r.cleanup,
|
||||
outcome: r.outcome,
|
||||
startedAt: r.startedAt,
|
||||
endedAt: r.endedAt,
|
||||
findings: r.findings,
|
||||
});
|
||||
}
|
||||
|
||||
// Multiple records: build combined message.
|
||||
// Include a strict raw-findings section so parent can reliably cover every task result.
|
||||
const parts: string[] = [
|
||||
`All ${records.length} background tasks have completed. Here are the combined results:`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const r = records[i]!;
|
||||
const displayName = r.label || r.task.slice(0, 60);
|
||||
const statusLabel = formatStatusLabel(r.outcome);
|
||||
const durationStr = (r.startedAt && r.endedAt)
|
||||
? ` (${formatDuration(r.startedAt, r.endedAt)})`
|
||||
: "";
|
||||
|
||||
parts.push(
|
||||
`### Task ${i + 1}: "${displayName}"`,
|
||||
`Status: ${statusLabel}${durationStr}`,
|
||||
"",
|
||||
"Findings:",
|
||||
r.findings || "(no output)",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
// Overall stats
|
||||
const allStartTimes = records.map(r => r.startedAt).filter(Boolean) as number[];
|
||||
const allEndTimes = records.map(r => r.endedAt).filter(Boolean) as number[];
|
||||
if (allStartTimes.length > 0 && allEndTimes.length > 0) {
|
||||
const wallTime = formatDuration(Math.min(...allStartTimes), Math.max(...allEndTimes));
|
||||
parts.push(`Total wall time: ${wallTime}`);
|
||||
}
|
||||
|
||||
const okCount = records.filter(r => r.outcome?.status === "ok").length;
|
||||
const failCount = records.length - okCount;
|
||||
parts.push(`Results: ${okCount} succeeded, ${failCount} failed/timed out`);
|
||||
|
||||
parts.push("", "Raw findings from each task (MUST cover all items):", "");
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const r = records[i]!;
|
||||
const displayName = r.label || r.task.slice(0, 60);
|
||||
parts.push(
|
||||
`[${i + 1}] ${displayName}:`,
|
||||
r.findings || "(no output)",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
"",
|
||||
"Summarize these results naturally for the user.",
|
||||
"You MUST include findings from every task item above, without omission.",
|
||||
"Keep it concise, but preserve concrete findings from each task.",
|
||||
"Do not mention technical details like session IDs or that these were background tasks.",
|
||||
"You can respond with NO_REPLY if no announcement is needed.",
|
||||
);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the coalesced announcement flow for all completed runs of a requester.
|
||||
* Formats a single combined message and delivers it to the parent agent.
|
||||
*/
|
||||
export function runCoalescedAnnounceFlow(
|
||||
requesterSessionId: string,
|
||||
records: SubagentRunRecord[],
|
||||
): boolean {
|
||||
const message = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
try {
|
||||
const hub = getHub();
|
||||
const parentAgent = hub.getAgent(requesterSessionId);
|
||||
if (!parentAgent || parentAgent.closed) {
|
||||
console.warn(
|
||||
`[SubagentAnnounce] Parent agent not found or closed: ${requesterSessionId}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[SubagentAnnounce] Failed to coalesced-announce to parent:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full subagent announcement flow:
|
||||
* 1. Read child's last assistant reply
|
||||
* 2. Format announcement message
|
||||
* 3. Send to parent agent via Hub
|
||||
*
|
||||
* @deprecated Use runCoalescedAnnounceFlow instead, which supports
|
||||
* batching multiple completed runs into a single announcement.
|
||||
*/
|
||||
export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean {
|
||||
const { requesterSessionId, childSessionId } = params;
|
||||
|
|
@ -204,7 +341,7 @@ export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean
|
|||
return false;
|
||||
}
|
||||
|
||||
parentAgent.write(message);
|
||||
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[SubagentAnnounce] Failed to announce to parent:`, err);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export {
|
|||
readLatestAssistantReply,
|
||||
formatAnnouncementMessage,
|
||||
runSubagentAnnounceFlow,
|
||||
formatCoalescedAnnouncementMessage,
|
||||
runCoalescedAnnounceFlow,
|
||||
} from "./announce.js";
|
||||
export type { FormatAnnouncementParams } from "./announce.js";
|
||||
|
||||
|
|
|
|||
75
src/agent/subagent/registry-recovery.test.ts
Normal file
75
src/agent/subagent/registry-recovery.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SubagentRunRecord } from "./types.js";
|
||||
|
||||
const loadSubagentRunsMock = vi.fn<() => Map<string, SubagentRunRecord>>();
|
||||
const saveSubagentRunsMock = vi.fn();
|
||||
const readLatestAssistantReplyMock = vi.fn();
|
||||
const runCoalescedAnnounceFlowMock = vi.fn(() => false);
|
||||
const resolveSessionDirMock = vi.fn((sessionId: string) => `/tmp/${sessionId}`);
|
||||
const closeAgentMock = vi.fn();
|
||||
const getHubMock = vi.fn(() => ({ closeAgent: closeAgentMock }));
|
||||
const rmSyncMock = vi.fn();
|
||||
|
||||
vi.mock("./registry-store.js", () => ({
|
||||
loadSubagentRuns: loadSubagentRunsMock,
|
||||
saveSubagentRuns: saveSubagentRunsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./announce.js", () => ({
|
||||
readLatestAssistantReply: readLatestAssistantReplyMock,
|
||||
runCoalescedAnnounceFlow: runCoalescedAnnounceFlowMock,
|
||||
}));
|
||||
|
||||
vi.mock("../session/storage.js", () => ({
|
||||
resolveSessionDir: resolveSessionDirMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../hub/hub-singleton.js", () => ({
|
||||
getHub: getHubMock,
|
||||
isHubInitialized: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
rmSync: rmSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe("subagent registry recovery cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
loadSubagentRunsMock.mockReturnValue(new Map());
|
||||
runCoalescedAnnounceFlowMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("deletes child session on recovery even when findings were already captured", async () => {
|
||||
const now = Date.now();
|
||||
const record: SubagentRunRecord = {
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "task",
|
||||
cleanup: "delete",
|
||||
createdAt: now - 1000,
|
||||
startedAt: now - 900,
|
||||
endedAt: now - 100,
|
||||
outcome: { status: "ok" },
|
||||
findings: "done",
|
||||
findingsCaptured: true,
|
||||
cleanupHandled: false,
|
||||
announced: false,
|
||||
};
|
||||
|
||||
loadSubagentRunsMock.mockReturnValue(new Map([["run-1", record]]));
|
||||
|
||||
const registry = await import("./registry.js");
|
||||
registry.initSubagentRegistry();
|
||||
|
||||
expect(readLatestAssistantReplyMock).not.toHaveBeenCalled();
|
||||
expect(resolveSessionDirMock).toHaveBeenCalledWith("child-1");
|
||||
expect(rmSyncMock).toHaveBeenCalledWith("/tmp/child-1", { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -78,4 +78,50 @@ describe("registry-store serialization", () => {
|
|||
expect(parsed.outcome?.status).toBe("error");
|
||||
expect(parsed.outcome?.error).toBe("Something went wrong");
|
||||
});
|
||||
|
||||
it("round-trips coalescing fields (findings, findingsCaptured, announced)", () => {
|
||||
const record: SubagentRunRecord = {
|
||||
runId: "run-coalesce",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Coalesce test",
|
||||
cleanup: "delete",
|
||||
createdAt: Date.now(),
|
||||
endedAt: Date.now() + 5000,
|
||||
outcome: { status: "ok" },
|
||||
findings: "Found 3 issues in auth module.",
|
||||
findingsCaptured: true,
|
||||
announced: true,
|
||||
};
|
||||
|
||||
const json = JSON.stringify({ version: 1, runs: { "run-coalesce": record } });
|
||||
const parsed = JSON.parse(json);
|
||||
const restored = parsed.runs["run-coalesce"] as SubagentRunRecord;
|
||||
|
||||
expect(restored.findings).toBe("Found 3 issues in auth module.");
|
||||
expect(restored.findingsCaptured).toBe(true);
|
||||
expect(restored.announced).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips record with undefined coalescing fields", () => {
|
||||
const record: SubagentRunRecord = {
|
||||
runId: "run-old",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Old record",
|
||||
cleanup: "delete",
|
||||
createdAt: Date.now(),
|
||||
cleanupHandled: true,
|
||||
// No findings, findingsCaptured, or announced fields (old format)
|
||||
};
|
||||
|
||||
const json = JSON.stringify({ version: 1, runs: { "run-old": record } });
|
||||
const parsed = JSON.parse(json);
|
||||
const restored = parsed.runs["run-old"] as SubagentRunRecord;
|
||||
|
||||
expect(restored.findings).toBeUndefined();
|
||||
expect(restored.findingsCaptured).toBeUndefined();
|
||||
expect(restored.announced).toBeUndefined();
|
||||
expect(restored.cleanupHandled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
registerSubagentRun,
|
||||
listSubagentRuns,
|
||||
|
|
@ -159,3 +159,118 @@ describe("subagent registry", () => {
|
|||
expect(getSubagentRun("run-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent registry — coalescing", () => {
|
||||
// Without Hub, watchChildAgent ends runs immediately with "Hub not initialized".
|
||||
// This allows us to test the coalescing state transitions.
|
||||
|
||||
it("captures findings when a run completes (no Hub)", () => {
|
||||
registerSubagentRun({
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task 1",
|
||||
});
|
||||
|
||||
const record = getSubagentRun("run-1");
|
||||
// Run ended immediately due to no Hub
|
||||
expect(record?.endedAt).toBeGreaterThan(0);
|
||||
expect(record?.findingsCaptured).toBe(true);
|
||||
});
|
||||
|
||||
it("does not announce while sibling runs are still pending", () => {
|
||||
// Register first run — ends immediately (no Hub)
|
||||
registerSubagentRun({
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task 1",
|
||||
});
|
||||
|
||||
const record1 = getSubagentRun("run-1");
|
||||
expect(record1?.findingsCaptured).toBe(true);
|
||||
|
||||
// Register second run — also ends immediately
|
||||
registerSubagentRun({
|
||||
runId: "run-2",
|
||||
childSessionId: "child-2",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task 2",
|
||||
});
|
||||
|
||||
const record2 = getSubagentRun("run-2");
|
||||
expect(record2?.findingsCaptured).toBe(true);
|
||||
|
||||
// Both ended, but announce fails because no Hub for parent agent.
|
||||
// The key check: both records should have findings captured.
|
||||
// announced will be false because runCoalescedAnnounceFlow fails (no Hub).
|
||||
expect(record1?.announced).toBeUndefined();
|
||||
expect(record2?.announced).toBeUndefined();
|
||||
});
|
||||
|
||||
it("single run captures findings immediately", () => {
|
||||
registerSubagentRun({
|
||||
runId: "run-solo",
|
||||
childSessionId: "child-solo",
|
||||
requesterSessionId: "parent-solo",
|
||||
task: "Solo task",
|
||||
});
|
||||
|
||||
const record = getSubagentRun("run-solo");
|
||||
expect(record?.endedAt).toBeGreaterThan(0);
|
||||
expect(record?.findingsCaptured).toBe(true);
|
||||
expect(record?.outcome?.status).toBe("error");
|
||||
expect(record?.outcome?.error).toContain("Hub not initialized");
|
||||
});
|
||||
|
||||
it("shutdownSubagentRegistry captures findings for ended-but-uncaptured runs", () => {
|
||||
registerSubagentRun({
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task",
|
||||
});
|
||||
|
||||
const record = getSubagentRun("run-1");
|
||||
if (record) {
|
||||
// Simulate: run ended but findings not yet captured
|
||||
record.endedAt = Date.now();
|
||||
record.outcome = { status: "ok" };
|
||||
record.findingsCaptured = undefined;
|
||||
}
|
||||
|
||||
shutdownSubagentRegistry();
|
||||
|
||||
expect(record?.findingsCaptured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent registry — post-announce cleanup", () => {
|
||||
it("removes runs from registry after successful announcement", async () => {
|
||||
// Mock runCoalescedAnnounceFlow to succeed
|
||||
const announceModule = await import("./announce.js");
|
||||
const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true);
|
||||
|
||||
// Register two runs for the same parent — both end immediately (no Hub)
|
||||
registerSubagentRun({
|
||||
runId: "run-a",
|
||||
childSessionId: "child-a",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task A",
|
||||
});
|
||||
registerSubagentRun({
|
||||
runId: "run-b",
|
||||
childSessionId: "child-b",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task B",
|
||||
});
|
||||
|
||||
// Both runs should have been announced and removed from registry
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(getSubagentRun("run-a")).toBeUndefined();
|
||||
expect(getSubagentRun("run-b")).toBeUndefined();
|
||||
expect(listSubagentRuns("parent-1")).toHaveLength(0);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { getHub, isHubInitialized } from "../../hub/hub-singleton.js";
|
||||
import { loadSubagentRuns, saveSubagentRuns } from "./registry-store.js";
|
||||
import { runSubagentAnnounceFlow } from "./announce.js";
|
||||
import { readLatestAssistantReply, runCoalescedAnnounceFlow } from "./announce.js";
|
||||
import type {
|
||||
RegisterSubagentRunParams,
|
||||
SubagentRunRecord,
|
||||
|
|
@ -27,7 +27,7 @@ const SWEEP_INTERVAL_MS = 60 * 1000;
|
|||
|
||||
const subagentRuns = new Map<string, SubagentRunRecord>();
|
||||
let sweepTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const resumedRuns = new Set<string>();
|
||||
const resumedRequesters = new Set<string>();
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
|
|
@ -39,25 +39,45 @@ export function initSubagentRegistry(): void {
|
|||
for (const [runId, record] of persisted) {
|
||||
subagentRuns.set(runId, record);
|
||||
|
||||
// Resume incomplete runs
|
||||
if (!record.cleanupHandled) {
|
||||
if (record.endedAt) {
|
||||
// Completed but cleanup not done — run announce flow
|
||||
if (!resumedRuns.has(runId)) {
|
||||
resumedRuns.add(runId);
|
||||
handleRunCompletion(record);
|
||||
}
|
||||
} else {
|
||||
// If not ended, the child agent session is lost on restart —
|
||||
// mark as ended with unknown outcome
|
||||
record.endedAt = Date.now();
|
||||
record.outcome = { status: "unknown" };
|
||||
persist();
|
||||
if (!resumedRuns.has(runId)) {
|
||||
resumedRuns.add(runId);
|
||||
handleRunCompletion(record);
|
||||
}
|
||||
}
|
||||
// Backward compat: old records with cleanupHandled but no announced field
|
||||
if (record.cleanupHandled && record.announced === undefined) {
|
||||
record.announced = true;
|
||||
record.findingsCaptured = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process incomplete runs
|
||||
const affectedRequesters = new Set<string>();
|
||||
|
||||
for (const record of subagentRuns.values()) {
|
||||
if (record.announced && record.cleanupHandled) continue; // Already fully done
|
||||
|
||||
if (!record.endedAt) {
|
||||
// Child was running when process crashed — mark as ended/unknown
|
||||
record.endedAt = Date.now();
|
||||
record.outcome = { status: "unknown" };
|
||||
}
|
||||
|
||||
if (!record.findingsCaptured) {
|
||||
captureFindings(record);
|
||||
}
|
||||
|
||||
// Recovery cleanup must be independent from findings capture:
|
||||
// the process may crash after captureFindings() persisted but before deletion.
|
||||
if (record.cleanup === "delete" && !record.cleanupHandled) {
|
||||
deleteChildSession(record.childSessionId);
|
||||
}
|
||||
|
||||
affectedRequesters.add(record.requesterSessionId);
|
||||
}
|
||||
|
||||
persist();
|
||||
|
||||
// For each affected requester, check if coalesced announcement is needed
|
||||
for (const requesterId of affectedRequesters) {
|
||||
if (!resumedRequesters.has(requesterId)) {
|
||||
resumedRequesters.add(requesterId);
|
||||
checkAndAnnounce(requesterId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,11 +158,17 @@ export function shutdownSubagentRegistry(): void {
|
|||
record.outcome = { status: "unknown" };
|
||||
updated++;
|
||||
}
|
||||
|
||||
// Opportunistically capture findings for ended-but-uncaptured runs
|
||||
if (record.endedAt && !record.findingsCaptured) {
|
||||
captureFindings(record);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
persist();
|
||||
console.log(`[SubagentRegistry] Marked ${updated} active run(s) as ended during shutdown`);
|
||||
console.log(`[SubagentRegistry] Processed ${updated} run(s) during shutdown`);
|
||||
}
|
||||
|
||||
stopSweeper();
|
||||
|
|
@ -151,7 +177,7 @@ export function shutdownSubagentRegistry(): void {
|
|||
/** Reset all state (for testing). */
|
||||
export function resetSubagentRegistryForTests(): void {
|
||||
subagentRuns.clear();
|
||||
resumedRuns.clear();
|
||||
resumedRequesters.clear();
|
||||
stopSweeper();
|
||||
}
|
||||
|
||||
|
|
@ -222,44 +248,76 @@ function watchChildAgent(record: SubagentRunRecord, timeoutSeconds?: number): vo
|
|||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cleanup + Announce
|
||||
// Cleanup + Announce (two-phase: capture findings, then coalesced announce)
|
||||
// ============================================================================
|
||||
|
||||
function handleRunCompletion(record: SubagentRunRecord): void {
|
||||
if (record.cleanupHandled) return;
|
||||
record.cleanupHandled = true;
|
||||
/** Phase 1: Capture child's findings before session deletion. */
|
||||
function captureFindings(record: SubagentRunRecord): void {
|
||||
try {
|
||||
const findings = readLatestAssistantReply(record.childSessionId);
|
||||
record.findings = findings ?? undefined;
|
||||
} catch {
|
||||
record.findings = "(failed to read findings)";
|
||||
}
|
||||
record.findingsCaptured = true;
|
||||
persist();
|
||||
}
|
||||
|
||||
// Run announce flow
|
||||
const announced = runSubagentAnnounceFlow({
|
||||
runId: record.runId,
|
||||
childSessionId: record.childSessionId,
|
||||
requesterSessionId: record.requesterSessionId,
|
||||
task: record.task,
|
||||
label: record.label,
|
||||
cleanup: record.cleanup,
|
||||
outcome: record.outcome,
|
||||
startedAt: record.startedAt,
|
||||
endedAt: record.endedAt,
|
||||
});
|
||||
/**
|
||||
* Phase 2: Check if all unannounced runs for this requester have completed.
|
||||
* If so, send a single coalesced announcement to the parent.
|
||||
*/
|
||||
function checkAndAnnounce(requesterSessionId: string): void {
|
||||
const allRuns = listSubagentRuns(requesterSessionId);
|
||||
|
||||
if (!announced) {
|
||||
console.warn(`[SubagentRegistry] Announce flow failed for run ${record.runId}`);
|
||||
// Allow retry on next restart if announce failed.
|
||||
record.cleanupHandled = false;
|
||||
// Only consider unannounced runs
|
||||
const pending = allRuns.filter(r => !r.announced);
|
||||
if (pending.length === 0) return;
|
||||
|
||||
// Are all unannounced runs done?
|
||||
const allDone = pending.every(r => r.endedAt !== undefined);
|
||||
if (!allDone) return;
|
||||
|
||||
// Have all had findings captured?
|
||||
const allCaptured = pending.every(r => r.findingsCaptured);
|
||||
if (!allCaptured) return;
|
||||
|
||||
// All done — send coalesced announcement
|
||||
const announced = runCoalescedAnnounceFlow(requesterSessionId, pending);
|
||||
|
||||
if (announced) {
|
||||
for (const r of pending) {
|
||||
r.announced = true;
|
||||
r.cleanupHandled = true;
|
||||
// Remove from registry immediately — findings already delivered to parent
|
||||
subagentRuns.delete(r.runId);
|
||||
}
|
||||
persist();
|
||||
return;
|
||||
if (subagentRuns.size === 0) {
|
||||
stopSweeper();
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[SubagentRegistry] Coalesced announce failed for requester ${requesterSessionId}`,
|
||||
);
|
||||
// Leave announced=false so initSubagentRegistry() can retry on restart
|
||||
}
|
||||
}
|
||||
|
||||
/** Entry point: called when a child completes. */
|
||||
function handleRunCompletion(record: SubagentRunRecord): void {
|
||||
// Phase 1: capture findings (before session deletion)
|
||||
if (!record.findingsCaptured) {
|
||||
captureFindings(record);
|
||||
|
||||
// Session cleanup (safe now that findings are persisted)
|
||||
if (record.cleanup === "delete") {
|
||||
deleteChildSession(record.childSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle session cleanup
|
||||
if (record.cleanup === "delete") {
|
||||
deleteChildSession(record.childSessionId);
|
||||
}
|
||||
|
||||
// Schedule archive
|
||||
record.archiveAtMs = Date.now() + DEFAULT_ARCHIVE_AFTER_MS;
|
||||
record.cleanupCompletedAt = Date.now();
|
||||
persist();
|
||||
// Phase 2: coalesced announce check
|
||||
checkAndAnnounce(record.requesterSessionId);
|
||||
}
|
||||
|
||||
function deleteChildSession(sessionId: string): void {
|
||||
|
|
@ -305,7 +363,6 @@ function sweep(): void {
|
|||
for (const [runId, record] of subagentRuns) {
|
||||
if (record.archiveAtMs !== undefined && record.archiveAtMs <= now) {
|
||||
subagentRuns.delete(runId);
|
||||
resumedRuns.delete(runId);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ export type SubagentRunRecord = {
|
|||
cleanupHandled?: boolean | undefined;
|
||||
/** Timestamp when cleanup completed */
|
||||
cleanupCompletedAt?: number | undefined;
|
||||
/** Captured findings from the child session's last assistant reply */
|
||||
findings?: string | undefined;
|
||||
/** Whether findings have been captured (safe to delete session after this) */
|
||||
findingsCaptured?: boolean | undefined;
|
||||
/** Whether the coalesced announcement has been sent to parent */
|
||||
announced?: boolean | undefined;
|
||||
};
|
||||
|
||||
/** Parameters for registering a new subagent run */
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
SystemPromptReport,
|
||||
} from "./types.js";
|
||||
import {
|
||||
buildHeartbeatSection,
|
||||
buildConditionalToolSections,
|
||||
buildExtraPromptSection,
|
||||
buildIdentitySection,
|
||||
|
|
@ -58,6 +59,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): {
|
|||
{ name: "user", lines: buildUserSection(profile, mode) },
|
||||
{ name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir) },
|
||||
{ name: "memory", lines: buildMemoryFileSection(profile, mode) },
|
||||
{ name: "heartbeat", lines: buildHeartbeatSection(profile, mode) },
|
||||
{ name: "safety", lines: buildSafetySection(includeSafety) },
|
||||
{ name: "tooling", lines: buildToolingSummary(tools, mode) },
|
||||
{ name: "tool-call-style", lines: buildToolCallStyleSection(mode) },
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { SAFETY_CONSTITUTION } from "./constitution.js";
|
||||
import { formatRuntimeLine } from "./runtime-info.js";
|
||||
import { resolveHeartbeatPrompt } from "../../heartbeat/heartbeat-text.js";
|
||||
import type {
|
||||
ProfileContent,
|
||||
RuntimeInfo,
|
||||
|
|
@ -97,13 +98,14 @@ export function buildWorkspaceSection(
|
|||
"## Profile",
|
||||
"",
|
||||
`Your profile directory: \`${profileDir}\``,
|
||||
"Use this as the base path for profile files (soul.md, user.md, memory.md, memory/*.md).",
|
||||
"Use this as the base path for profile files (soul.md, user.md, memory.md, heartbeat.md, memory/*.md).",
|
||||
"",
|
||||
"Profile files:",
|
||||
"- `soul.md` — Your identity and values",
|
||||
"- `user.md` — Information about your user",
|
||||
"- `workspace.md` — Guidelines and conventions (below)",
|
||||
"- `memory.md` — Persistent knowledge",
|
||||
"- `heartbeat.md` — Background heartbeat loop instructions",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
|
@ -128,6 +130,26 @@ export function buildMemoryFileSection(
|
|||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat section — full mode only.
|
||||
* Keeps heartbeat protocol explicit in the agent instructions.
|
||||
*/
|
||||
export function buildHeartbeatSection(
|
||||
profile: ProfileContent | undefined,
|
||||
mode: SystemPromptMode,
|
||||
): string[] {
|
||||
if (mode !== "full") return [];
|
||||
const prompt = resolveHeartbeatPrompt(profile?.config?.heartbeat?.prompt);
|
||||
return [
|
||||
"## Heartbeats",
|
||||
`Heartbeat prompt: ${prompt}`,
|
||||
'If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:',
|
||||
"HEARTBEAT_OK",
|
||||
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety constitution — always included.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export interface ProfileContent {
|
|||
user?: string | undefined;
|
||||
workspace?: string | undefined;
|
||||
memory?: string | undefined;
|
||||
heartbeat?: string | undefined;
|
||||
config?: ProfileConfig | undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { createProcessTool } from "./tools/process.js";
|
|||
import { createGlobTool } from "./tools/glob.js";
|
||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn.js";
|
||||
import { createSessionsListTool } from "./tools/sessions-list.js";
|
||||
import { createMemorySearchTool } from "./tools/memory-search.js";
|
||||
import { createCronTool } from "./tools/cron/index.js";
|
||||
import { filterTools } from "./tools/policy.js";
|
||||
import { isMulticaError, isRetryableError } from "../shared/errors.js";
|
||||
import type { ExecApprovalCallback } from "./tools/exec-approval-types.js";
|
||||
|
|
@ -107,6 +109,8 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
const webFetchTool = createWebFetchTool();
|
||||
const webSearchTool = createWebSearchTool();
|
||||
|
||||
const cronTool = createCronTool();
|
||||
|
||||
const tools: AgentTool<any>[] = [
|
||||
...baseTools,
|
||||
execTool as AgentTool<any>,
|
||||
|
|
@ -114,6 +118,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
globTool as AgentTool<any>,
|
||||
webFetchTool as AgentTool<any>,
|
||||
webSearchTool as AgentTool<any>,
|
||||
cronTool as AgentTool<any>,
|
||||
];
|
||||
|
||||
// Add memory_search tool if profileDir is provided
|
||||
|
|
@ -129,6 +134,10 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
});
|
||||
tools.push(sessionsSpawnTool as AgentTool<any>);
|
||||
|
||||
// Add sessions_list tool
|
||||
const sessionsListTool = createSessionsListTool({ sessionId });
|
||||
tools.push(sessionsListTool as AgentTool<any>);
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
|
|
|
|||
422
src/agent/tools/cron/cron-tool.ts
Normal file
422
src/agent/tools/cron/cron-tool.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
/**
|
||||
* Cron Tool for Agent
|
||||
*
|
||||
* Allows agents to create, manage, and execute scheduled tasks.
|
||||
* Based on OpenClaw's implementation (MIT License)
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
getCronService,
|
||||
formatSchedule,
|
||||
formatDuration,
|
||||
parseTimeInput,
|
||||
parseIntervalInput,
|
||||
isValidCronExpr,
|
||||
type CronSchedule,
|
||||
type CronJobInput,
|
||||
} from "../../../cron/index.js";
|
||||
|
||||
// NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf.
|
||||
// Some providers reject anyOf in tool schemas; a flat string enum is safer.
|
||||
function stringEnum<T extends readonly string[]>(values: T, options: { description?: string } = {}) {
|
||||
return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
|
||||
}
|
||||
|
||||
const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "logs"] as const;
|
||||
|
||||
// Flattened schema: runtime validates per-action requirements.
|
||||
const CronSchema = Type.Object({
|
||||
action: stringEnum(CRON_ACTIONS),
|
||||
enabled: Type.Optional(Type.Boolean({ description: "Filter by enabled status (for list action)" })),
|
||||
name: Type.Optional(Type.String({ description: "Job name (for add action)" })),
|
||||
description: Type.Optional(Type.String({ description: "Job description (for add action)" })),
|
||||
schedule: Type.Optional(Type.Object({
|
||||
kind: stringEnum(["at", "every", "cron"] as const),
|
||||
at: Type.Optional(Type.String({ description: "Time for one-shot (ISO 8601 or relative like '10m')" })),
|
||||
every: Type.Optional(Type.String({ description: "Interval (e.g., '30m', '2h')" })),
|
||||
expr: Type.Optional(Type.String({ description: "Cron expression (5-field)" })),
|
||||
tz: Type.Optional(Type.String({ description: "Timezone for cron expression" })),
|
||||
})),
|
||||
sessionTarget: stringEnum(["main", "isolated"] as const, { description: "Where to run: main session or isolated" }),
|
||||
payload: Type.Optional(Type.Object({
|
||||
kind: stringEnum(["system-event", "agent-turn"] as const),
|
||||
text: Type.Optional(Type.String({ description: "Text for system-event" })),
|
||||
message: Type.Optional(Type.String({ description: "Prompt for agent-turn" })),
|
||||
timeoutSeconds: Type.Optional(Type.Number({ description: "Timeout for agent-turn" })),
|
||||
})),
|
||||
deleteAfterRun: Type.Optional(Type.Boolean({ description: "Delete after one-time run" })),
|
||||
wakeMode: stringEnum(["next-heartbeat", "now"] as const, { description: "When to wake after job execution" }),
|
||||
jobId: Type.Optional(Type.String({ description: "Job ID (for update/remove/run/logs actions)" })),
|
||||
force: Type.Optional(Type.Boolean({ description: "Force run even if disabled (for run action)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Number of log entries to return (for logs action)" })),
|
||||
});
|
||||
|
||||
type CronArgs = {
|
||||
action: "status" | "list" | "add" | "update" | "remove" | "run" | "logs";
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
description?: string;
|
||||
schedule?: {
|
||||
kind: "at" | "every" | "cron";
|
||||
at?: string;
|
||||
every?: string;
|
||||
expr?: string;
|
||||
tz?: string;
|
||||
};
|
||||
sessionTarget?: "main" | "isolated";
|
||||
payload?: {
|
||||
kind: "system-event" | "agent-turn";
|
||||
text?: string;
|
||||
message?: string;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
deleteAfterRun?: boolean;
|
||||
wakeMode?: "next-heartbeat" | "now";
|
||||
jobId?: string;
|
||||
force?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type CronResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
/** Parse schedule from tool parameters */
|
||||
function parseSchedule(schedule: CronArgs["schedule"]): CronSchedule | { error: string } {
|
||||
if (!schedule) {
|
||||
return { error: "schedule is required" };
|
||||
}
|
||||
|
||||
switch (schedule.kind) {
|
||||
case "at": {
|
||||
const at = schedule.at;
|
||||
if (!at) {
|
||||
return { error: "schedule.at is required for kind='at'" };
|
||||
}
|
||||
const atMs = parseTimeInput(at);
|
||||
if (!atMs) {
|
||||
return { error: `Invalid time format: ${at}` };
|
||||
}
|
||||
return { kind: "at", atMs };
|
||||
}
|
||||
|
||||
case "every": {
|
||||
const every = schedule.every;
|
||||
if (!every) {
|
||||
return { error: "schedule.every is required for kind='every'" };
|
||||
}
|
||||
const everyMs = parseIntervalInput(every);
|
||||
if (!everyMs) {
|
||||
return { error: `Invalid interval format: ${every}` };
|
||||
}
|
||||
return { kind: "every", everyMs };
|
||||
}
|
||||
|
||||
case "cron": {
|
||||
const expr = schedule.expr;
|
||||
if (!expr) {
|
||||
return { error: "schedule.expr is required for kind='cron'" };
|
||||
}
|
||||
const tz = schedule.tz;
|
||||
if (!isValidCronExpr(expr, tz)) {
|
||||
return { error: `Invalid cron expression: ${expr}` };
|
||||
}
|
||||
// Only include tz if defined (exactOptionalPropertyTypes)
|
||||
if (tz) {
|
||||
return { kind: "cron", expr, tz };
|
||||
}
|
||||
return { kind: "cron", expr };
|
||||
}
|
||||
|
||||
default:
|
||||
return { error: `Unknown schedule kind: ${schedule.kind}` };
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_DESCRIPTION = `Manage cron jobs (status/list/add/update/remove/run/logs).
|
||||
|
||||
ACTIONS:
|
||||
- status: Check cron scheduler status
|
||||
- list: List jobs (use enabled:true/false to filter)
|
||||
- add: Create job (requires name, schedule, payload, sessionTarget)
|
||||
- update: Modify job (requires jobId, plus fields to update)
|
||||
- remove: Delete job (requires jobId)
|
||||
- run: Trigger job immediately (requires jobId, optional force:true)
|
||||
- logs: Get job run history (requires jobId, optional limit)
|
||||
|
||||
SCHEDULE TYPES (schedule.kind):
|
||||
- "at": One-shot at time
|
||||
{ "kind": "at", "at": "10m" } or { "kind": "at", "at": "2024-12-31T23:59:00Z" }
|
||||
- "every": Recurring interval
|
||||
{ "kind": "every", "every": "30m" }
|
||||
- "cron": Cron expression
|
||||
{ "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" }
|
||||
|
||||
PAYLOAD TYPES (payload.kind):
|
||||
- "system-event": Injects text into main session (like a reminder, triggers main agent to respond)
|
||||
{ "kind": "system-event", "text": "<message>" }
|
||||
- "agent-turn": Spawns an isolated agent that can use ALL tools (exec, write, web_fetch, etc.) to autonomously complete a task
|
||||
{ "kind": "agent-turn", "message": "<prompt>", "timeoutSeconds": 300 }
|
||||
|
||||
USE "agent-turn" when the job needs to perform actions (run commands, write files, fetch data, etc.).
|
||||
USE "system-event" when the job only needs to remind/notify the user in the current chat.
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- sessionTarget="main" REQUIRES payload.kind="system-event"
|
||||
- sessionTarget="isolated" REQUIRES payload.kind="agent-turn"
|
||||
- Default sessionTarget is "main", default wakeMode is "now"`;
|
||||
|
||||
/** Create the cron tool */
|
||||
export function createCronTool(): AgentTool<typeof CronSchema, CronResult> {
|
||||
return {
|
||||
name: "cron",
|
||||
label: "Cron",
|
||||
description: TOOL_DESCRIPTION,
|
||||
parameters: CronSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const { action } = args as CronArgs;
|
||||
const service = getCronService();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "status": {
|
||||
const status = service.status();
|
||||
const output = JSON.stringify({
|
||||
running: status.running,
|
||||
enabled: status.enabled,
|
||||
jobCount: status.jobCount,
|
||||
enabledJobCount: status.enabledJobCount,
|
||||
nextWakeAt: status.nextWakeAtMs ? new Date(status.nextWakeAtMs).toISOString() : null,
|
||||
storePath: status.storePath,
|
||||
}, null, 2);
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: { success: true, message: "Status retrieved", data: status },
|
||||
};
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const params = args as CronArgs;
|
||||
const filter = params.enabled !== undefined ? { enabled: params.enabled } : undefined;
|
||||
const jobs = service.list(filter);
|
||||
const formatted = jobs.map((job) => ({
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
enabled: job.enabled,
|
||||
schedule: formatSchedule(job.schedule),
|
||||
sessionTarget: job.sessionTarget,
|
||||
nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null,
|
||||
lastStatus: job.state.lastStatus,
|
||||
lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null,
|
||||
}));
|
||||
const output = JSON.stringify(formatted, null, 2);
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: { success: true, message: `Found ${jobs.length} job(s)`, data: formatted },
|
||||
};
|
||||
}
|
||||
|
||||
case "add": {
|
||||
const params = args as CronArgs;
|
||||
if (!params.name) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: name is required" }],
|
||||
details: { success: false, message: "name is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const schedule = parseSchedule(params.schedule);
|
||||
if ("error" in schedule) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${schedule.error}` }],
|
||||
details: { success: false, message: schedule.error },
|
||||
};
|
||||
}
|
||||
|
||||
if (!params.payload) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: payload is required" }],
|
||||
details: { success: false, message: "payload is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const { payload } = params;
|
||||
let jobPayload;
|
||||
if (payload.kind === "system-event") {
|
||||
if (!payload.text) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: payload.text is required for system-event" }],
|
||||
details: { success: false, message: "payload.text is required for system-event" },
|
||||
};
|
||||
}
|
||||
jobPayload = { kind: "system-event" as const, text: payload.text };
|
||||
} else if (payload.kind === "agent-turn") {
|
||||
if (!payload.message) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: payload.message is required for agent-turn" }],
|
||||
details: { success: false, message: "payload.message is required for agent-turn" },
|
||||
};
|
||||
}
|
||||
const agentPayload: { kind: "agent-turn"; message: string; timeoutSeconds?: number } = {
|
||||
kind: "agent-turn",
|
||||
message: payload.message,
|
||||
};
|
||||
if (payload.timeoutSeconds !== undefined) {
|
||||
agentPayload.timeoutSeconds = payload.timeoutSeconds;
|
||||
}
|
||||
jobPayload = agentPayload;
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: Unknown payload kind` }],
|
||||
details: { success: false, message: "Unknown payload kind" },
|
||||
};
|
||||
}
|
||||
|
||||
const input: CronJobInput = {
|
||||
name: params.name,
|
||||
enabled: true,
|
||||
schedule,
|
||||
sessionTarget: params.sessionTarget ?? "main",
|
||||
wakeMode: params.wakeMode ?? "now",
|
||||
payload: jobPayload,
|
||||
};
|
||||
if (params.description !== undefined) {
|
||||
input.description = params.description;
|
||||
}
|
||||
if (params.deleteAfterRun !== undefined) {
|
||||
input.deleteAfterRun = params.deleteAfterRun;
|
||||
}
|
||||
|
||||
const job = service.add(input);
|
||||
const output = `Created job: ${job.name} (${job.id})\nSchedule: ${formatSchedule(job.schedule)}\nNext run: ${job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : "none"}`;
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: { success: true, message: "Job created", data: job },
|
||||
};
|
||||
}
|
||||
|
||||
case "update": {
|
||||
const params = args as CronArgs;
|
||||
if (!params.jobId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: jobId is required" }],
|
||||
details: { success: false, message: "jobId is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (params.name !== undefined) patch.name = params.name;
|
||||
if (params.description !== undefined) patch.description = params.description;
|
||||
if (params.enabled !== undefined) patch.enabled = params.enabled;
|
||||
if (params.schedule !== undefined) {
|
||||
const schedule = parseSchedule(params.schedule);
|
||||
if ("error" in schedule) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${schedule.error}` }],
|
||||
details: { success: false, message: schedule.error },
|
||||
};
|
||||
}
|
||||
patch.schedule = schedule;
|
||||
}
|
||||
|
||||
const updated = service.update(params.jobId, patch);
|
||||
if (!updated) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: Job not found: ${params.jobId}` }],
|
||||
details: { success: false, message: "Job not found" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Updated job: ${updated.name} (${updated.id})` }],
|
||||
details: { success: true, message: "Job updated", data: updated },
|
||||
};
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
const params = args as CronArgs;
|
||||
if (!params.jobId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: jobId is required" }],
|
||||
details: { success: false, message: "jobId is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const removed = service.remove(params.jobId);
|
||||
if (!removed) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: Job not found: ${params.jobId}` }],
|
||||
details: { success: false, message: "Job not found" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Removed job: ${params.jobId}` }],
|
||||
details: { success: true, message: "Job removed" },
|
||||
};
|
||||
}
|
||||
|
||||
case "run": {
|
||||
const params = args as CronArgs;
|
||||
if (!params.jobId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: jobId is required" }],
|
||||
details: { success: false, message: "jobId is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await service.run(params.jobId, params.force);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${result.reason}` }],
|
||||
details: { success: false, message: result.reason ?? "Run failed" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: "Job executed successfully" }],
|
||||
details: { success: true, message: "Job executed" },
|
||||
};
|
||||
}
|
||||
|
||||
case "logs": {
|
||||
const params = args as CronArgs;
|
||||
if (!params.jobId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: jobId is required" }],
|
||||
details: { success: false, message: "jobId is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const logs = service.getRunLogs(params.jobId, params.limit);
|
||||
const formatted = logs.map((log) => ({
|
||||
timestamp: new Date(log.ts).toISOString(),
|
||||
status: log.status,
|
||||
duration: log.durationMs ? formatDuration(log.durationMs) : undefined,
|
||||
error: log.error,
|
||||
summary: log.summary,
|
||||
}));
|
||||
const output = JSON.stringify(formatted, null, 2);
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: { success: true, message: `Found ${logs.length} log entries`, data: formatted },
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: Unknown action: ${action}` }],
|
||||
details: { success: false, message: `Unknown action: ${action}` },
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${message}` }],
|
||||
details: { success: false, message },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
5
src/agent/tools/cron/index.ts
Normal file
5
src/agent/tools/cron/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Cron Tools
|
||||
*/
|
||||
|
||||
export { createCronTool } from "./cron-tool.js";
|
||||
|
|
@ -50,8 +50,8 @@ export function createCliApprovalCallback(
|
|||
const runtimeConfig = { ...config, allowlist: [...(config.allowlist ?? [])] };
|
||||
|
||||
return async (command: string, cwd: string | undefined): Promise<ApprovalResult> => {
|
||||
const security = runtimeConfig.security ?? "allowlist";
|
||||
const ask = runtimeConfig.ask ?? "on-miss";
|
||||
const security = runtimeConfig.security ?? "full";
|
||||
const ask = runtimeConfig.ask ?? "off";
|
||||
const timeoutMs = runtimeConfig.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
|
||||
// Security: deny blocks everything
|
||||
|
|
@ -137,13 +137,15 @@ function promptTerminal(
|
|||
rl.close();
|
||||
};
|
||||
|
||||
// Timeout: auto-deny
|
||||
const timer = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
|
||||
cleanup();
|
||||
resolve("deny");
|
||||
}, timeoutMs);
|
||||
// Timeout: auto-deny (skip if timeoutMs is -1 for no timeout)
|
||||
const timer = timeoutMs >= 0
|
||||
? setTimeout(() => {
|
||||
if (resolved) return;
|
||||
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
|
||||
cleanup();
|
||||
resolve("deny");
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
// Display approval prompt
|
||||
process.stderr.write("\n");
|
||||
|
|
@ -161,7 +163,7 @@ function promptTerminal(
|
|||
rl.question(
|
||||
` ${bold("[a]")}llow once / ${bold("[A]")}llow always / ${bold("[d]")}eny (default: deny): `,
|
||||
(answer) => {
|
||||
clearTimeout(timer);
|
||||
if (timer) clearTimeout(timer);
|
||||
cleanup();
|
||||
|
||||
const trimmed = answer.trim();
|
||||
|
|
@ -177,7 +179,7 @@ function promptTerminal(
|
|||
|
||||
// Handle Ctrl+C gracefully
|
||||
rl.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve("deny");
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export interface ExecApprovalRequest {
|
|||
riskLevel: "safe" | "needs-review" | "dangerous";
|
||||
/** Reasons for the risk assessment */
|
||||
riskReasons: string[];
|
||||
/** When this approval expires (ms since epoch) */
|
||||
/** When this approval expires (ms since epoch). -1 means no timeout. */
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ export interface ExecApprovalConfig {
|
|||
security?: ExecSecurity;
|
||||
/** Ask mode: "off" never asks, "on-miss" asks when allowlist misses, "always" always asks */
|
||||
ask?: ExecAsk;
|
||||
/** Timeout before auto-deny in milliseconds (default: 60_000) */
|
||||
/** Timeout before auto-deny in milliseconds (default: 60_000). Set to -1 for no timeout. */
|
||||
timeoutMs?: number;
|
||||
/** Fallback security level on timeout (default: "deny" — fail-closed) */
|
||||
askFallback?: ExecSecurity;
|
||||
|
|
@ -58,8 +58,8 @@ export interface ExecApprovalConfig {
|
|||
allowlist?: ExecAllowlistEntry[];
|
||||
}
|
||||
|
||||
/** Default timeout for approval requests (60 seconds) */
|
||||
export const DEFAULT_APPROVAL_TIMEOUT_MS = 60_000;
|
||||
/** Default timeout for approval requests (-1 = no timeout, wait indefinitely) */
|
||||
export const DEFAULT_APPROVAL_TIMEOUT_MS = -1;
|
||||
|
||||
// ============ Allowlist ============
|
||||
|
||||
|
|
|
|||
|
|
@ -164,9 +164,22 @@ export function createExecTool(
|
|||
// Don't reject, let close event handle
|
||||
});
|
||||
|
||||
// Signal handling: don't kill if already backgrounded
|
||||
const onAbort = signal ? () => {
|
||||
if (yielded) return; // Already backgrounded, ignore abort
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
child.kill("SIGTERM");
|
||||
} : undefined;
|
||||
|
||||
if (signal && onAbort) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
||||
|
||||
// If already backgrounded, don't resolve again
|
||||
if (yielded) return;
|
||||
|
|
@ -202,16 +215,6 @@ export function createExecTool(
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Signal handling: don't kill if already backgrounded
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
if (yielded) return; // Already backgrounded, ignore abort
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
|||
"group:memory": ["memory_search"],
|
||||
|
||||
// Subagent tools
|
||||
"group:subagent": ["sessions_spawn"],
|
||||
"group:subagent": ["sessions_spawn", "sessions_list"],
|
||||
|
||||
// Cron/scheduling tools
|
||||
"group:cron": ["cron"],
|
||||
|
||||
// All core tools
|
||||
"group:core": [
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export { createExecTool } from "./exec.js";
|
|||
export { createProcessTool } from "./process.js";
|
||||
export { createGlobTool } from "./glob.js";
|
||||
export { createWebFetchTool, createWebSearchTool } from "./web/index.js";
|
||||
export { createCronTool } from "./cron/index.js";
|
||||
export { createSessionsListTool } from "./sessions-list.js";
|
||||
|
||||
// Tool groups
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function createProcessTool(defaultCwd?: string): AgentTool<typeof Process
|
|||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
resolve({ success: true });
|
||||
|
|
|
|||
169
src/agent/tools/sessions-list.test.ts
Normal file
169
src/agent/tools/sessions-list.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { SubagentRunRecord } from "../subagent/types.js";
|
||||
|
||||
// Mock the registry module before importing the tool
|
||||
vi.mock("../subagent/registry.js", () => ({
|
||||
listSubagentRuns: vi.fn(),
|
||||
getSubagentRun: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createSessionsListTool } from "./sessions-list.js";
|
||||
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
|
||||
|
||||
const mockListSubagentRuns = vi.mocked(listSubagentRuns);
|
||||
const mockGetSubagentRun = vi.mocked(getSubagentRun);
|
||||
|
||||
function makeRecord(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
|
||||
return {
|
||||
runId: "run-001",
|
||||
childSessionId: "child-001",
|
||||
requesterSessionId: "parent-001",
|
||||
task: "Test task",
|
||||
cleanup: "delete",
|
||||
createdAt: 1700000000000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sessions_list tool", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty message when no runs exist", async () => {
|
||||
mockListSubagentRuns.mockReturnValue([]);
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", {});
|
||||
|
||||
expect(result.content[0]).toEqual({
|
||||
type: "text",
|
||||
text: "No subagent runs for this session.",
|
||||
});
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("lists multiple runs with correct status mapping", async () => {
|
||||
const now = Date.now();
|
||||
const runs: SubagentRunRecord[] = [
|
||||
makeRecord({
|
||||
runId: "run-aaa",
|
||||
label: "Code Review",
|
||||
startedAt: now - 45000,
|
||||
}),
|
||||
makeRecord({
|
||||
runId: "run-bbb",
|
||||
label: "Test Analysis",
|
||||
startedAt: now - 60000,
|
||||
endedAt: now - 30000,
|
||||
outcome: { status: "ok" },
|
||||
}),
|
||||
makeRecord({
|
||||
runId: "run-ccc",
|
||||
label: "Lint Check",
|
||||
startedAt: now - 60000,
|
||||
endedAt: now,
|
||||
outcome: { status: "error", error: "timeout" },
|
||||
}),
|
||||
];
|
||||
mockListSubagentRuns.mockReturnValue(runs);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", {});
|
||||
|
||||
const text = result.content[0]!;
|
||||
expect(text.type).toBe("text");
|
||||
expect((text as { text: string }).text).toContain("3 total");
|
||||
expect((text as { text: string }).text).toContain("[running]");
|
||||
expect((text as { text: string }).text).toContain("[ok]");
|
||||
expect((text as { text: string }).text).toContain("[error]");
|
||||
expect((text as { text: string }).text).toContain("Code Review");
|
||||
expect((text as { text: string }).text).toContain("Test Analysis");
|
||||
expect((text as { text: string }).text).toContain("Lint Check");
|
||||
|
||||
expect(result.details!.runs).toHaveLength(3);
|
||||
expect(result.details!.runs[0]!.status).toBe("running");
|
||||
expect(result.details!.runs[1]!.status).toBe("ok");
|
||||
expect(result.details!.runs[2]!.status).toBe("error");
|
||||
});
|
||||
|
||||
it("returns detail for a specific runId", async () => {
|
||||
const now = Date.now();
|
||||
const record = makeRecord({
|
||||
runId: "run-detail",
|
||||
label: "Deep Analysis",
|
||||
task: "Analyze the authentication module thoroughly",
|
||||
startedAt: now - 90000,
|
||||
endedAt: now - 10000,
|
||||
outcome: { status: "ok" },
|
||||
findings: "Found 2 potential issues in token validation.",
|
||||
findingsCaptured: true,
|
||||
});
|
||||
mockGetSubagentRun.mockReturnValue(record);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "run-detail" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Run: run-detail");
|
||||
expect(text).toContain("Label: Deep Analysis");
|
||||
expect(text).toContain("Status: ok");
|
||||
expect(text).toContain("Found 2 potential issues");
|
||||
expect(text).toContain("Duration:");
|
||||
|
||||
expect(result.details!.runs).toHaveLength(1);
|
||||
expect(result.details!.runs[0]!.runId).toBe("run-detail");
|
||||
});
|
||||
|
||||
it("returns not found for unknown runId", async () => {
|
||||
mockGetSubagentRun.mockReturnValue(undefined);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "nonexistent" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Run not found");
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("rejects runId belonging to a different requester", async () => {
|
||||
const record = makeRecord({
|
||||
runId: "run-other",
|
||||
requesterSessionId: "other-parent",
|
||||
});
|
||||
mockGetSubagentRun.mockReturnValue(record);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "run-other" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Run not found");
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("handles missing sessionId gracefully", async () => {
|
||||
const tool = createSessionsListTool({});
|
||||
const result = await tool.execute("call-1", {});
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("No session ID available");
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("shows findings status for running task", async () => {
|
||||
const now = Date.now();
|
||||
const record = makeRecord({
|
||||
runId: "run-running",
|
||||
label: "Still Running",
|
||||
startedAt: now - 30000,
|
||||
// no endedAt
|
||||
});
|
||||
mockGetSubagentRun.mockReturnValue(record);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "run-running" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Status: running");
|
||||
expect(text).toContain("Findings: (still running)");
|
||||
});
|
||||
});
|
||||
187
src/agent/tools/sessions-list.ts
Normal file
187
src/agent/tools/sessions-list.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* sessions_list tool — allows an agent to view its spawned subagent runs.
|
||||
*
|
||||
* Lists all subagent runs for the current session, or shows details for a
|
||||
* specific run when a runId is provided.
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
|
||||
import type { SubagentRunRecord } from "../subagent/types.js";
|
||||
|
||||
const SessionsListSchema = Type.Object({
|
||||
runId: Type.Optional(
|
||||
Type.String({ description: "Optional run ID to get details for a specific run. If omitted, lists all runs." }),
|
||||
),
|
||||
});
|
||||
|
||||
type SessionsListArgs = {
|
||||
runId?: string;
|
||||
};
|
||||
|
||||
export type SessionsListResult = {
|
||||
runs: Array<{
|
||||
runId: string;
|
||||
label?: string;
|
||||
task: string;
|
||||
status: "running" | "ok" | "error" | "timeout" | "unknown";
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
findings?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export interface CreateSessionsListToolOptions {
|
||||
/** Session ID of the current (requester) agent */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
function resolveStatus(record: SubagentRunRecord): "running" | "ok" | "error" | "timeout" | "unknown" {
|
||||
if (!record.endedAt) return "running";
|
||||
return record.outcome?.status ?? "unknown";
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes < 60) return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function formatRunSummary(record: SubagentRunRecord, index: number, now: number): string {
|
||||
const status = resolveStatus(record);
|
||||
const displayName = record.label || record.task.slice(0, 60);
|
||||
const statusTag = `[${status}]`.padEnd(10);
|
||||
|
||||
let timing = "";
|
||||
if (status === "running" && record.startedAt) {
|
||||
timing = `started ${formatElapsed(now - record.startedAt)} ago`;
|
||||
} else if (record.startedAt && record.endedAt) {
|
||||
timing = `completed in ${formatElapsed(record.endedAt - record.startedAt)}`;
|
||||
}
|
||||
|
||||
const parts = [`#${index + 1} ${statusTag} "${displayName}"`];
|
||||
if (timing) parts.push(`(${record.runId.slice(0, 8)}…, ${timing})`);
|
||||
else parts.push(`(${record.runId.slice(0, 8)}…)`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function formatRunDetail(record: SubagentRunRecord, now: number): string {
|
||||
const status = resolveStatus(record);
|
||||
const lines: string[] = [
|
||||
`Run: ${record.runId}`,
|
||||
];
|
||||
|
||||
if (record.label) lines.push(`Label: ${record.label}`);
|
||||
lines.push(`Task: ${record.task}`);
|
||||
lines.push(`Status: ${status}${record.outcome?.error ? ` — ${record.outcome.error}` : ""}`);
|
||||
lines.push(`Child Session: ${record.childSessionId}`);
|
||||
lines.push(`Created: ${new Date(record.createdAt).toISOString()} (${formatElapsed(now - record.createdAt)} ago)`);
|
||||
|
||||
if (record.startedAt) {
|
||||
lines.push(`Started: ${new Date(record.startedAt).toISOString()} (${formatElapsed(now - record.startedAt)} ago)`);
|
||||
}
|
||||
if (record.endedAt) {
|
||||
lines.push(`Ended: ${new Date(record.endedAt).toISOString()}`);
|
||||
if (record.startedAt) {
|
||||
lines.push(`Duration: ${formatElapsed(record.endedAt - record.startedAt)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (record.findingsCaptured) {
|
||||
lines.push(`Findings: ${record.findings || "(no output)"}`);
|
||||
} else if (record.endedAt) {
|
||||
lines.push("Findings: (not yet captured)");
|
||||
} else {
|
||||
lines.push("Findings: (still running)");
|
||||
}
|
||||
|
||||
if (record.announced) lines.push("Announced: yes");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function toResultRun(record: SubagentRunRecord) {
|
||||
return {
|
||||
runId: record.runId,
|
||||
label: record.label,
|
||||
task: record.task,
|
||||
status: resolveStatus(record),
|
||||
startedAt: record.startedAt,
|
||||
endedAt: record.endedAt,
|
||||
findings: record.findings,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionsListTool(
|
||||
options: CreateSessionsListToolOptions,
|
||||
): AgentTool<typeof SessionsListSchema, SessionsListResult> {
|
||||
return {
|
||||
name: "sessions_list",
|
||||
label: "List Subagent Runs",
|
||||
description:
|
||||
"List all subagent runs spawned by this session and their current status. " +
|
||||
"Optionally pass a runId to get detailed information about a specific run.",
|
||||
parameters: SessionsListSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const { runId } = args as SessionsListArgs;
|
||||
const requesterSessionId = options.sessionId;
|
||||
|
||||
if (!requesterSessionId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No session ID available. Cannot list subagent runs." }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Detail mode: specific run
|
||||
if (runId) {
|
||||
const record = getSubagentRun(runId);
|
||||
if (!record) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Run not found: ${runId}` }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
if (record.requesterSessionId !== requesterSessionId) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Run not found: ${runId}` }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: formatRunDetail(record, now) }],
|
||||
details: { runs: [toResultRun(record)] },
|
||||
};
|
||||
}
|
||||
|
||||
// List mode: all runs for this session
|
||||
const runs = listSubagentRuns(requesterSessionId);
|
||||
|
||||
if (runs.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No subagent runs for this session." }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const lines = [`Subagent runs for this session: ${runs.length} total`, ""];
|
||||
for (let i = 0; i < runs.length; i++) {
|
||||
lines.push(formatRunSummary(runs[i]!, i, now));
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { runs: runs.map(toResultRun) },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
147
src/cron/execute.ts
Normal file
147
src/cron/execute.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* Cron Job Execution
|
||||
*
|
||||
* Handles the actual execution of cron job payloads.
|
||||
* Based on OpenClaw's implementation (MIT License)
|
||||
*/
|
||||
|
||||
import type { CronJob } from "./types.js";
|
||||
import { getHub, isHubInitialized } from "../hub/hub-singleton.js";
|
||||
|
||||
/** Execution result */
|
||||
export type ExecutionResult = {
|
||||
summary?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a cron job payload.
|
||||
*
|
||||
* For system-event: Injects text into the main session
|
||||
* For agent-turn: Creates an isolated agent turn
|
||||
*/
|
||||
export async function executeCronJob(job: CronJob): Promise<ExecutionResult> {
|
||||
const { payload } = job;
|
||||
|
||||
switch (payload.kind) {
|
||||
case "system-event":
|
||||
return executeSystemEvent(job);
|
||||
case "agent-turn":
|
||||
return executeAgentTurn(job);
|
||||
default:
|
||||
return { error: `Unknown payload kind: ${(payload as { kind: string }).kind}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a system-event payload.
|
||||
* Injects the text into the main session as a system message.
|
||||
*/
|
||||
async function executeSystemEvent(job: CronJob): Promise<ExecutionResult> {
|
||||
if (!isHubInitialized()) {
|
||||
return { error: "Hub not available" };
|
||||
}
|
||||
const hub = getHub();
|
||||
|
||||
const payload = job.payload as { kind: "system-event"; text: string };
|
||||
const text = payload.text.trim();
|
||||
if (!text) {
|
||||
return { error: "system-event payload requires non-empty text" };
|
||||
}
|
||||
|
||||
// Get the list of active agents
|
||||
const agentIds = hub.listAgents();
|
||||
if (agentIds.length === 0) {
|
||||
return { error: "No active agents" };
|
||||
}
|
||||
|
||||
// For now, inject into the first (main) agent
|
||||
// TODO: Support targeting specific agent by ID
|
||||
const agentId = agentIds[0]!;
|
||||
const cronMessage = `[CRON] ${job.name}: ${text}`;
|
||||
|
||||
hub.enqueueSystemEvent(cronMessage, { agentId });
|
||||
|
||||
if (job.wakeMode === "now") {
|
||||
const result = await hub.runHeartbeatOnce({ reason: `cron:${job.id}` });
|
||||
if (result.status === "failed") {
|
||||
return { error: result.reason };
|
||||
}
|
||||
if (result.status === "skipped") {
|
||||
return {
|
||||
summary: `Enqueued cron event for agent ${agentId.slice(0, 8)} (wake skipped: ${result.reason})`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
summary: `Enqueued cron event and triggered immediate heartbeat for agent ${agentId.slice(0, 8)}`,
|
||||
};
|
||||
}
|
||||
|
||||
hub.requestHeartbeatNow({ reason: `cron:${job.id}` });
|
||||
return {
|
||||
summary: `Enqueued cron event for agent ${agentId.slice(0, 8)} (wakeMode: next-heartbeat)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an agent-turn payload.
|
||||
* Creates an isolated subagent to run the task.
|
||||
*/
|
||||
async function executeAgentTurn(job: CronJob): Promise<ExecutionResult> {
|
||||
if (!isHubInitialized()) {
|
||||
return { error: "Hub not available" };
|
||||
}
|
||||
const hub = getHub();
|
||||
|
||||
const payload = job.payload as {
|
||||
kind: "agent-turn";
|
||||
message: string;
|
||||
model?: string;
|
||||
thinkingLevel?: string;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
// Generate a unique session ID for this isolated run
|
||||
const sessionId = `cron-${job.id}-${Date.now()}`;
|
||||
|
||||
try {
|
||||
// Create isolated subagent
|
||||
// TODO: Support model/thinkingLevel override
|
||||
const agent = hub.createSubagent(sessionId, {
|
||||
profileId: "default",
|
||||
});
|
||||
|
||||
// Set up timeout if specified
|
||||
const timeoutMs = (payload.timeoutSeconds ?? 300) * 1000; // default 5 minutes
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
|
||||
const timeoutPromise = new Promise<ExecutionResult>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`Cron job timed out after ${payload.timeoutSeconds}s`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
// Execute the agent turn
|
||||
const executePromise = (async (): Promise<ExecutionResult> => {
|
||||
const cronMessage = `[CRON Job: ${job.name}]\n\n${payload.message}`;
|
||||
agent.write(cronMessage);
|
||||
await agent.waitForIdle();
|
||||
return { summary: `Completed agent turn in isolated session ${sessionId.slice(0, 16)}` };
|
||||
})();
|
||||
|
||||
// Race between execution and timeout
|
||||
const result = await Promise.race([executePromise, timeoutPromise]);
|
||||
|
||||
// Clear timeout
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
// Close the subagent
|
||||
agent.close();
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
39
src/cron/index.ts
Normal file
39
src/cron/index.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Cron Module
|
||||
*
|
||||
* Provides scheduled task functionality for Super Multica.
|
||||
*/
|
||||
|
||||
export type {
|
||||
CronSchedule,
|
||||
CronSessionTarget,
|
||||
CronWakeMode,
|
||||
CronPayload,
|
||||
CronJobState,
|
||||
CronJob,
|
||||
CronJobInput,
|
||||
CronJobPatch,
|
||||
CronRunLogEntry,
|
||||
CronConfig,
|
||||
} from "./types.js";
|
||||
|
||||
export {
|
||||
computeNextRunAtMs,
|
||||
isValidCronExpr,
|
||||
parseTimeInput,
|
||||
parseIntervalInput,
|
||||
formatSchedule,
|
||||
formatDuration,
|
||||
} from "./schedule.js";
|
||||
|
||||
export { CronStore } from "./store.js";
|
||||
|
||||
export {
|
||||
CronService,
|
||||
getCronService,
|
||||
shutdownCronService,
|
||||
type CronJobExecutor,
|
||||
type CronServiceStatus,
|
||||
} from "./service.js";
|
||||
|
||||
export { executeCronJob, type ExecutionResult } from "./execute.js";
|
||||
187
src/cron/schedule.ts
Normal file
187
src/cron/schedule.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Cron Schedule Computation
|
||||
*
|
||||
* Based on OpenClaw's implementation (MIT License)
|
||||
*/
|
||||
|
||||
import { Cron } from "croner";
|
||||
import type { CronSchedule } from "./types.js";
|
||||
|
||||
/**
|
||||
* Compute the next run time for a schedule.
|
||||
*
|
||||
* @param schedule - The schedule configuration
|
||||
* @param nowMs - Current time in milliseconds (default: Date.now())
|
||||
* @returns Next run time in ms, or undefined if no future run
|
||||
*/
|
||||
export function computeNextRunAtMs(
|
||||
schedule: CronSchedule,
|
||||
nowMs: number = Date.now(),
|
||||
): number | undefined {
|
||||
switch (schedule.kind) {
|
||||
case "at":
|
||||
// One-shot: return the timestamp if it's in the future
|
||||
return schedule.atMs > nowMs ? schedule.atMs : undefined;
|
||||
|
||||
case "every": {
|
||||
// Fixed interval: compute next occurrence
|
||||
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
|
||||
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
|
||||
|
||||
if (nowMs < anchor) return anchor;
|
||||
|
||||
const elapsed = nowMs - anchor;
|
||||
const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
|
||||
return anchor + steps * everyMs;
|
||||
}
|
||||
|
||||
case "cron": {
|
||||
// Cron expression: use croner to compute next run
|
||||
const expr = schedule.expr.trim();
|
||||
if (!expr) return undefined;
|
||||
|
||||
try {
|
||||
const tz = schedule.tz?.trim();
|
||||
const cron = tz ? new Cron(expr, { timezone: tz }) : new Cron(expr);
|
||||
const next = cron.nextRun(new Date(nowMs));
|
||||
return next ? next.getTime() : undefined;
|
||||
} catch (error) {
|
||||
console.error(`[Cron] Invalid cron expression: ${expr}`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression.
|
||||
*
|
||||
* @param expr - Cron expression (5-field)
|
||||
* @param tz - Optional timezone
|
||||
* @returns true if valid, false otherwise
|
||||
*/
|
||||
export function isValidCronExpr(expr: string, tz?: string): boolean {
|
||||
try {
|
||||
const timezone = tz?.trim();
|
||||
if (timezone) {
|
||||
new Cron(expr.trim(), { timezone });
|
||||
} else {
|
||||
new Cron(expr.trim());
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a human-readable time string into milliseconds.
|
||||
*
|
||||
* Supports:
|
||||
* - Relative: "10s", "5m", "2h", "1d"
|
||||
* - ISO 8601: "2024-01-15T09:00:00Z"
|
||||
* - Unix timestamp (if numeric)
|
||||
*
|
||||
* @param input - Time string
|
||||
* @param nowMs - Current time for relative calculations
|
||||
* @returns Timestamp in ms, or undefined if invalid
|
||||
*/
|
||||
export function parseTimeInput(input: string, nowMs: number = Date.now()): number | undefined {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Check for relative time (e.g., "10m", "2h")
|
||||
const relativeMatch = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhd])$/i);
|
||||
if (relativeMatch) {
|
||||
const [, numStr, unit] = relativeMatch;
|
||||
const num = parseFloat(numStr!);
|
||||
const multipliers: Record<string, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
const ms = multipliers[unit!.toLowerCase()];
|
||||
if (ms !== undefined) {
|
||||
return nowMs + num * ms;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for numeric (unix timestamp in ms or seconds)
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const num = parseInt(trimmed, 10);
|
||||
// If it looks like seconds (before year 2100), convert to ms
|
||||
if (num < 4102444800) {
|
||||
return num * 1000;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
// Try ISO 8601 date parsing
|
||||
const date = new Date(trimmed);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an interval string into milliseconds.
|
||||
*
|
||||
* Supports: "30s", "5m", "2h", "1d", or raw milliseconds
|
||||
*
|
||||
* @param input - Interval string
|
||||
* @returns Interval in ms, or undefined if invalid
|
||||
*/
|
||||
export function parseIntervalInput(input: string): number | undefined {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Check for duration format (e.g., "30m", "2h")
|
||||
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhd])$/i);
|
||||
if (match) {
|
||||
const [, numStr, unit] = match;
|
||||
const num = parseFloat(numStr!);
|
||||
const multipliers: Record<string, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
const ms = multipliers[unit!.toLowerCase()];
|
||||
if (ms !== undefined) {
|
||||
return num * ms;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for raw milliseconds
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return parseInt(trimmed, 10);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a schedule for display.
|
||||
*/
|
||||
export function formatSchedule(schedule: CronSchedule): string {
|
||||
switch (schedule.kind) {
|
||||
case "at":
|
||||
return `at ${new Date(schedule.atMs).toISOString()}`;
|
||||
case "every":
|
||||
return `every ${formatDuration(schedule.everyMs)}`;
|
||||
case "cron":
|
||||
return `cron "${schedule.expr}"${schedule.tz ? ` (${schedule.tz})` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds as human-readable duration.
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60 * 1000) return `${Math.round(ms / 1000)}s`;
|
||||
if (ms < 60 * 60 * 1000) return `${Math.round(ms / (60 * 1000))}m`;
|
||||
if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h`;
|
||||
return `${Math.round(ms / (24 * 60 * 60 * 1000))}d`;
|
||||
}
|
||||
432
src/cron/service.ts
Normal file
432
src/cron/service.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
/**
|
||||
* Cron Service
|
||||
*
|
||||
* Manages scheduled jobs with timer-based execution.
|
||||
* Based on OpenClaw's implementation (MIT License)
|
||||
*/
|
||||
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type {
|
||||
CronJob,
|
||||
CronJobInput,
|
||||
CronJobPatch,
|
||||
CronJobState,
|
||||
CronRunLogEntry,
|
||||
CronConfig,
|
||||
} from "./types.js";
|
||||
import { CronStore } from "./store.js";
|
||||
import { computeNextRunAtMs } from "./schedule.js";
|
||||
|
||||
/** Callback for job execution */
|
||||
export type CronJobExecutor = (job: CronJob) => Promise<{ summary?: string; error?: string }>;
|
||||
|
||||
/** Service status */
|
||||
export type CronServiceStatus = {
|
||||
running: boolean;
|
||||
enabled: boolean;
|
||||
storePath: string;
|
||||
jobCount: number;
|
||||
enabledJobCount: number;
|
||||
nextWakeAtMs: number | null;
|
||||
};
|
||||
|
||||
/** Default stuck job timeout (2 hours) */
|
||||
const STUCK_JOB_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
export class CronService {
|
||||
private readonly store: CronStore;
|
||||
private readonly config: CronConfig;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
private executor: CronJobExecutor | null = null;
|
||||
|
||||
constructor(config: CronConfig = {}) {
|
||||
this.config = {
|
||||
enabled: config.enabled ?? true,
|
||||
maxConcurrentRuns: config.maxConcurrentRuns ?? 1,
|
||||
...config,
|
||||
};
|
||||
this.store = new CronStore(config.storePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the job executor callback.
|
||||
* This is called when a job needs to be executed.
|
||||
*/
|
||||
setExecutor(executor: CronJobExecutor): void {
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cron service.
|
||||
* Loads jobs from disk, computes schedules, and starts the timer.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
if (!this.config.enabled) {
|
||||
console.log("[CronService] Cron is disabled by config");
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
console.log("[CronService] Starting...");
|
||||
|
||||
// Load jobs and compute next run times
|
||||
const jobs = this.store.load();
|
||||
console.log(`[CronService] Loaded ${jobs.length} jobs`);
|
||||
|
||||
// Recompute all schedules
|
||||
this.recomputeAllSchedules();
|
||||
|
||||
// Clear any stuck jobs (running for > 2 hours)
|
||||
this.clearStuckJobs();
|
||||
|
||||
// Arm timer for next job
|
||||
this.armTimer();
|
||||
|
||||
console.log("[CronService] Started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cron service.
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.running) return;
|
||||
|
||||
this.running = false;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
console.log("[CronService] Stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service status.
|
||||
*/
|
||||
status(): CronServiceStatus {
|
||||
const allJobs = this.store.list();
|
||||
const enabledJobs = this.store.list({ enabled: true });
|
||||
|
||||
const nextWake = enabledJobs.reduce((min, job) => {
|
||||
const next = job.state.nextRunAtMs;
|
||||
return next !== undefined && next < min ? next : min;
|
||||
}, Infinity);
|
||||
|
||||
return {
|
||||
running: this.running,
|
||||
enabled: this.config.enabled ?? true,
|
||||
storePath: this.store.getStorePath(),
|
||||
jobCount: allJobs.length,
|
||||
enabledJobCount: enabledJobs.length,
|
||||
nextWakeAtMs: nextWake === Infinity ? null : nextWake,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List jobs with optional filter.
|
||||
*/
|
||||
list(filter?: { enabled?: boolean }): CronJob[] {
|
||||
return this.store.list(filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a job by ID.
|
||||
*/
|
||||
get(id: string): CronJob | undefined {
|
||||
return this.store.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new job.
|
||||
*/
|
||||
add(input: CronJobInput): CronJob {
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
...input,
|
||||
id: uuidv7(),
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
state: {},
|
||||
};
|
||||
|
||||
// Compute initial next run time
|
||||
this.computeNextRun(job);
|
||||
|
||||
this.store.set(job);
|
||||
console.log(`[CronService] Added job: ${job.name} (${job.id}), next run: ${job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : "none"}`);
|
||||
|
||||
// Re-arm timer in case this job runs sooner
|
||||
if (this.running) {
|
||||
this.armTimer();
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing job.
|
||||
*/
|
||||
update(id: string, patch: CronJobPatch): CronJob | null {
|
||||
const job = this.store.get(id);
|
||||
if (!job) return null;
|
||||
|
||||
// Apply patch
|
||||
Object.assign(job, patch, { updatedAtMs: Date.now() });
|
||||
|
||||
// Recompute schedule if changed
|
||||
if (patch.schedule || patch.enabled !== undefined) {
|
||||
this.computeNextRun(job);
|
||||
}
|
||||
|
||||
this.store.set(job);
|
||||
console.log(`[CronService] Updated job: ${job.name} (${job.id})`);
|
||||
|
||||
// Re-arm timer
|
||||
if (this.running) {
|
||||
this.armTimer();
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a job.
|
||||
*/
|
||||
remove(id: string): boolean {
|
||||
const job = this.store.get(id);
|
||||
if (!job) return false;
|
||||
|
||||
const deleted = this.store.delete(id);
|
||||
if (deleted) {
|
||||
console.log(`[CronService] Removed job: ${job.name} (${id})`);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a job immediately.
|
||||
*
|
||||
* @param id - Job ID
|
||||
* @param force - Run even if disabled
|
||||
*/
|
||||
async run(id: string, force = false): Promise<{ ok: boolean; reason?: string }> {
|
||||
const job = this.store.get(id);
|
||||
if (!job) {
|
||||
return { ok: false, reason: "Job not found" };
|
||||
}
|
||||
|
||||
if (!job.enabled && !force) {
|
||||
return { ok: false, reason: "Job is disabled" };
|
||||
}
|
||||
|
||||
if (job.state.runningAtMs) {
|
||||
return { ok: false, reason: "Job is already running" };
|
||||
}
|
||||
|
||||
await this.executeJob(job);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get run logs for a job.
|
||||
*/
|
||||
getRunLogs(id: string, limit?: number): CronRunLogEntry[] {
|
||||
return this.store.getRunLogs(id, limit);
|
||||
}
|
||||
|
||||
// === Private Methods ===
|
||||
|
||||
/**
|
||||
* Compute next run time for a job.
|
||||
*/
|
||||
private computeNextRun(job: CronJob): void {
|
||||
if (!job.enabled) {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const nextMs = computeNextRunAtMs(job.schedule, now);
|
||||
job.state.nextRunAtMs = nextMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute schedules for all enabled jobs.
|
||||
*/
|
||||
private recomputeAllSchedules(): void {
|
||||
for (const job of this.store.list({ enabled: true })) {
|
||||
this.computeNextRun(job);
|
||||
this.store.set(job);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stuck jobs (running for too long).
|
||||
*/
|
||||
private clearStuckJobs(): void {
|
||||
const now = Date.now();
|
||||
for (const job of this.store.list()) {
|
||||
if (job.state.runningAtMs && now - job.state.runningAtMs > STUCK_JOB_TIMEOUT_MS) {
|
||||
console.warn(`[CronService] Clearing stuck job: ${job.name} (${job.id})`);
|
||||
job.state.runningAtMs = undefined;
|
||||
job.state.lastStatus = "error";
|
||||
job.state.lastError = "Job was stuck (running > 2 hours)";
|
||||
this.store.set(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the timer for the next due job.
|
||||
*/
|
||||
private armTimer(): void {
|
||||
if (!this.running) return;
|
||||
|
||||
// Clear existing timer
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
// Find next wake time
|
||||
const enabledJobs = this.store.list({ enabled: true });
|
||||
const nextWake = enabledJobs.reduce((min, job) => {
|
||||
const next = job.state.nextRunAtMs;
|
||||
return next !== undefined && next < min ? next : min;
|
||||
}, Infinity);
|
||||
|
||||
if (nextWake === Infinity) {
|
||||
// No jobs to run
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = Math.max(0, nextWake - Date.now());
|
||||
this.timer = setTimeout(() => this.onTimer(), delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer callback: run all due jobs.
|
||||
*/
|
||||
private async onTimer(): Promise<void> {
|
||||
if (!this.running) return;
|
||||
|
||||
const now = Date.now();
|
||||
const dueJobs = this.store
|
||||
.list({ enabled: true })
|
||||
.filter((j) => {
|
||||
const next = j.state.nextRunAtMs;
|
||||
return next !== undefined && next <= now && !j.state.runningAtMs;
|
||||
});
|
||||
|
||||
for (const job of dueJobs) {
|
||||
try {
|
||||
await this.executeJob(job);
|
||||
} catch (error) {
|
||||
console.error(`[CronService] Error executing job ${job.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-arm timer for next batch
|
||||
this.armTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single job.
|
||||
*/
|
||||
private async executeJob(job: CronJob): Promise<void> {
|
||||
const startMs = Date.now();
|
||||
console.log(`[CronService] Executing job: ${job.name} (${job.id})`);
|
||||
|
||||
// Mark as running
|
||||
job.state.runningAtMs = startMs;
|
||||
this.store.set(job);
|
||||
|
||||
let status: "ok" | "error" = "ok";
|
||||
let error: string | undefined;
|
||||
let summary: string | undefined;
|
||||
|
||||
try {
|
||||
if (this.executor) {
|
||||
const result = await this.executor(job);
|
||||
summary = result.summary;
|
||||
if (result.error) {
|
||||
status = "error";
|
||||
error = result.error;
|
||||
}
|
||||
} else {
|
||||
// No executor set, just log
|
||||
console.log(`[CronService] Job ${job.id} payload:`, job.payload);
|
||||
}
|
||||
} catch (err) {
|
||||
status = "error";
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[CronService] Job ${job.id} failed:`, err);
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startMs;
|
||||
|
||||
// Update job state
|
||||
job.state.runningAtMs = undefined;
|
||||
job.state.lastRunAtMs = startMs;
|
||||
job.state.lastStatus = status;
|
||||
job.state.lastError = error;
|
||||
job.state.lastDurationMs = durationMs;
|
||||
|
||||
// Handle one-shot jobs
|
||||
if (job.schedule.kind === "at") {
|
||||
if (status === "ok" && job.deleteAfterRun) {
|
||||
this.store.delete(job.id);
|
||||
console.log(`[CronService] Deleted one-shot job: ${job.name} (${job.id})`);
|
||||
} else {
|
||||
job.enabled = false;
|
||||
job.state.nextRunAtMs = undefined;
|
||||
this.store.set(job);
|
||||
}
|
||||
} else {
|
||||
// Compute next run for recurring jobs
|
||||
this.computeNextRun(job);
|
||||
this.store.set(job);
|
||||
}
|
||||
|
||||
// Append run log
|
||||
this.store.appendRunLog(job.id, {
|
||||
ts: startMs,
|
||||
jobId: job.id,
|
||||
action: status === "ok" ? "run" : "error",
|
||||
status,
|
||||
error,
|
||||
summary,
|
||||
durationMs,
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
|
||||
console.log(`[CronService] Job ${job.id} completed: ${status} (${durationMs}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let cronServiceInstance: CronService | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the singleton CronService instance.
|
||||
*/
|
||||
export function getCronService(config?: CronConfig): CronService {
|
||||
if (!cronServiceInstance) {
|
||||
cronServiceInstance = new CronService(config);
|
||||
}
|
||||
return cronServiceInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the singleton CronService.
|
||||
*/
|
||||
export function shutdownCronService(): void {
|
||||
if (cronServiceInstance) {
|
||||
cronServiceInstance.stop();
|
||||
cronServiceInstance = null;
|
||||
}
|
||||
}
|
||||
215
src/cron/store.ts
Normal file
215
src/cron/store.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* Cron Job Storage
|
||||
*
|
||||
* Persists jobs to JSON file and run logs to JSONL files.
|
||||
* Based on OpenClaw's implementation (MIT License)
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync } from "fs";
|
||||
import path from "path";
|
||||
import type { CronJob, CronRunLogEntry } from "./types.js";
|
||||
|
||||
/** Default cron storage directory */
|
||||
const DEFAULT_CRON_DIR = path.join(
|
||||
process.env["HOME"] ?? ".",
|
||||
".super-multica",
|
||||
"cron",
|
||||
);
|
||||
|
||||
/** Store data structure */
|
||||
type StoreData = {
|
||||
version: number;
|
||||
jobs: CronJob[];
|
||||
};
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
|
||||
export class CronStore {
|
||||
private readonly jobsPath: string;
|
||||
private readonly runsDir: string;
|
||||
private jobs: Map<string, CronJob> = new Map();
|
||||
private loaded = false;
|
||||
|
||||
constructor(baseDir: string = DEFAULT_CRON_DIR) {
|
||||
this.jobsPath = path.join(baseDir, "jobs.json");
|
||||
this.runsDir = path.join(baseDir, "runs");
|
||||
}
|
||||
|
||||
/** Ensure directories exist */
|
||||
private ensureDirs() {
|
||||
const dir = path.dirname(this.jobsPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
if (!existsSync(this.runsDir)) {
|
||||
mkdirSync(this.runsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Load jobs from disk */
|
||||
load(): CronJob[] {
|
||||
this.ensureDirs();
|
||||
|
||||
if (!existsSync(this.jobsPath)) {
|
||||
this.jobs = new Map();
|
||||
this.loaded = true;
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(this.jobsPath, "utf-8");
|
||||
const data: StoreData = JSON.parse(raw);
|
||||
|
||||
// Validate version
|
||||
if (data.version !== STORE_VERSION) {
|
||||
console.warn(`[CronStore] Store version mismatch: ${data.version} vs ${STORE_VERSION}`);
|
||||
}
|
||||
|
||||
this.jobs = new Map(data.jobs.map((j) => [j.id, j]));
|
||||
this.loaded = true;
|
||||
return Array.from(this.jobs.values());
|
||||
} catch (error) {
|
||||
console.error("[CronStore] Failed to load jobs:", error);
|
||||
this.jobs = new Map();
|
||||
this.loaded = true;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Save jobs to disk */
|
||||
save(): void {
|
||||
this.ensureDirs();
|
||||
|
||||
const data: StoreData = {
|
||||
version: STORE_VERSION,
|
||||
jobs: Array.from(this.jobs.values()),
|
||||
};
|
||||
|
||||
// Write to temp file first, then rename (atomic)
|
||||
const tmpPath = this.jobsPath + ".tmp";
|
||||
const bakPath = this.jobsPath + ".bak";
|
||||
|
||||
try {
|
||||
writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8");
|
||||
|
||||
// Backup existing file
|
||||
if (existsSync(this.jobsPath)) {
|
||||
writeFileSync(bakPath, readFileSync(this.jobsPath));
|
||||
}
|
||||
|
||||
// Rename temp to actual (atomic on most filesystems)
|
||||
renameSync(tmpPath, this.jobsPath);
|
||||
} catch (error) {
|
||||
console.error("[CronStore] Failed to save jobs:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure store is loaded */
|
||||
private ensureLoaded() {
|
||||
if (!this.loaded) {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a job by ID */
|
||||
get(id: string): CronJob | undefined {
|
||||
this.ensureLoaded();
|
||||
return this.jobs.get(id);
|
||||
}
|
||||
|
||||
/** Set (create or update) a job */
|
||||
set(job: CronJob): void {
|
||||
this.ensureLoaded();
|
||||
this.jobs.set(job.id, job);
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Delete a job by ID */
|
||||
delete(id: string): boolean {
|
||||
this.ensureLoaded();
|
||||
const deleted = this.jobs.delete(id);
|
||||
if (deleted) {
|
||||
this.save();
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** List all jobs, optionally filtered */
|
||||
list(filter?: { enabled?: boolean }): CronJob[] {
|
||||
this.ensureLoaded();
|
||||
let jobs = Array.from(this.jobs.values());
|
||||
|
||||
if (filter?.enabled !== undefined) {
|
||||
jobs = jobs.filter((j) => j.enabled === filter.enabled);
|
||||
}
|
||||
|
||||
// Sort by next run time
|
||||
jobs.sort((a, b) => {
|
||||
const aNext = a.state.nextRunAtMs ?? Infinity;
|
||||
const bNext = b.state.nextRunAtMs ?? Infinity;
|
||||
return aNext - bNext;
|
||||
});
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
/** Get job count */
|
||||
count(filter?: { enabled?: boolean }): number {
|
||||
return this.list(filter).length;
|
||||
}
|
||||
|
||||
// === Run Log Methods ===
|
||||
|
||||
/** Append a run log entry */
|
||||
appendRunLog(jobId: string, entry: CronRunLogEntry): void {
|
||||
this.ensureDirs();
|
||||
const logPath = path.join(this.runsDir, `${jobId}.jsonl`);
|
||||
const line = JSON.stringify(entry) + "\n";
|
||||
appendFileSync(logPath, line, "utf-8");
|
||||
}
|
||||
|
||||
/** Get run logs for a job */
|
||||
getRunLogs(jobId: string, limit = 50): CronRunLogEntry[] {
|
||||
const logPath = path.join(this.runsDir, `${jobId}.jsonl`);
|
||||
|
||||
if (!existsSync(logPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(logPath, "utf-8").trim();
|
||||
if (!content) return [];
|
||||
|
||||
const lines = content.split("\n");
|
||||
const entries = lines
|
||||
.slice(-limit)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as CronRunLogEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((e): e is CronRunLogEntry => e !== null);
|
||||
|
||||
return entries;
|
||||
} catch (error) {
|
||||
console.error(`[CronStore] Failed to read run logs for ${jobId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear run logs for a job */
|
||||
clearRunLogs(jobId: string): void {
|
||||
const logPath = path.join(this.runsDir, `${jobId}.jsonl`);
|
||||
if (existsSync(logPath)) {
|
||||
writeFileSync(logPath, "", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the store path (for status display) */
|
||||
getStorePath(): string {
|
||||
return this.jobsPath;
|
||||
}
|
||||
}
|
||||
116
src/cron/types.ts
Normal file
116
src/cron/types.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Cron Job Types
|
||||
*
|
||||
* Based on OpenClaw's implementation (MIT License)
|
||||
*/
|
||||
|
||||
/** Cron schedule: one-shot, interval, or cron expression */
|
||||
export type CronSchedule =
|
||||
| { kind: "at"; atMs: number }
|
||||
| { kind: "every"; everyMs: number; anchorMs?: number }
|
||||
| { kind: "cron"; expr: string; tz?: string };
|
||||
|
||||
/** Where to run the job */
|
||||
export type CronSessionTarget = "main" | "isolated";
|
||||
|
||||
/** When to wake after job execution */
|
||||
export type CronWakeMode = "next-heartbeat" | "now";
|
||||
|
||||
/** Job payload: what to execute */
|
||||
export type CronPayload =
|
||||
| {
|
||||
kind: "system-event";
|
||||
/** Text to inject into main session */
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
kind: "agent-turn";
|
||||
/** Message/prompt for the agent */
|
||||
message: string;
|
||||
/** Optional model override (e.g., "anthropic/claude-3-opus") */
|
||||
model?: string;
|
||||
/** Optional thinking level override */
|
||||
thinkingLevel?: string;
|
||||
/** Timeout in seconds */
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
/** Runtime state of a job */
|
||||
export type CronJobState = {
|
||||
/** Next scheduled run (ms since epoch) */
|
||||
nextRunAtMs?: number | undefined;
|
||||
/** Currently running (lock marker, ms since epoch) */
|
||||
runningAtMs?: number | undefined;
|
||||
/** Last completed run (ms since epoch) */
|
||||
lastRunAtMs?: number | undefined;
|
||||
/** Last run status */
|
||||
lastStatus?: "ok" | "error" | "skipped" | undefined;
|
||||
/** Last error message */
|
||||
lastError?: string | undefined;
|
||||
/** Last run duration in ms */
|
||||
lastDurationMs?: number | undefined;
|
||||
};
|
||||
|
||||
/** Cron job definition */
|
||||
export type CronJob = {
|
||||
/** Unique identifier (UUIDv7) */
|
||||
id: string;
|
||||
/** User-friendly name */
|
||||
name: string;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Whether the job is enabled */
|
||||
enabled: boolean;
|
||||
/** Delete after successful one-shot run */
|
||||
deleteAfterRun?: boolean;
|
||||
/** Creation timestamp (ms) */
|
||||
createdAtMs: number;
|
||||
/** Last update timestamp (ms) */
|
||||
updatedAtMs: number;
|
||||
/** When to run */
|
||||
schedule: CronSchedule;
|
||||
/** Where to run (main session or isolated) */
|
||||
sessionTarget: CronSessionTarget;
|
||||
/** Wake mode after execution */
|
||||
wakeMode: CronWakeMode;
|
||||
/** What to execute */
|
||||
payload: CronPayload;
|
||||
/** Runtime state */
|
||||
state: CronJobState;
|
||||
};
|
||||
|
||||
/** Input for creating a new job (without auto-generated fields) */
|
||||
export type CronJobInput = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" | "state">;
|
||||
|
||||
/** Input for updating an existing job */
|
||||
export type CronJobPatch = Partial<Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs">>;
|
||||
|
||||
/** Run log entry */
|
||||
export type CronRunLogEntry = {
|
||||
/** Timestamp (ms) */
|
||||
ts: number;
|
||||
/** Job ID */
|
||||
jobId: string;
|
||||
/** Action taken */
|
||||
action: "run" | "skip" | "error";
|
||||
/** Result status */
|
||||
status: "ok" | "error" | "skipped";
|
||||
/** Error message if failed */
|
||||
error?: string | undefined;
|
||||
/** Summary of execution (for agent-turn) */
|
||||
summary?: string | undefined;
|
||||
/** Duration in ms */
|
||||
durationMs?: number | undefined;
|
||||
/** Next scheduled run */
|
||||
nextRunAtMs?: number | undefined;
|
||||
};
|
||||
|
||||
/** Cron service configuration */
|
||||
export type CronConfig = {
|
||||
/** Whether cron is enabled (default: true) */
|
||||
enabled?: boolean;
|
||||
/** Custom store path */
|
||||
storePath?: string;
|
||||
/** Max concurrent job runs (default: 1) */
|
||||
maxConcurrentRuns?: number;
|
||||
};
|
||||
50
src/heartbeat/heartbeat-events.ts
Normal file
50
src/heartbeat/heartbeat-events.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export type HeartbeatIndicatorType = "ok" | "alert" | "error";
|
||||
|
||||
export type HeartbeatEventPayload = {
|
||||
ts: number;
|
||||
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
|
||||
preview?: string;
|
||||
durationMs?: number;
|
||||
reason?: string;
|
||||
indicatorType?: HeartbeatIndicatorType;
|
||||
};
|
||||
|
||||
export function resolveIndicatorType(
|
||||
status: HeartbeatEventPayload["status"],
|
||||
): HeartbeatIndicatorType | undefined {
|
||||
switch (status) {
|
||||
case "ok-empty":
|
||||
case "ok-token":
|
||||
return "ok";
|
||||
case "sent":
|
||||
return "alert";
|
||||
case "failed":
|
||||
return "error";
|
||||
case "skipped":
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let lastHeartbeat: HeartbeatEventPayload | null = null;
|
||||
const listeners = new Set<(evt: HeartbeatEventPayload) => void>();
|
||||
|
||||
export function emitHeartbeatEvent(evt: Omit<HeartbeatEventPayload, "ts">): void {
|
||||
const enriched: HeartbeatEventPayload = { ts: Date.now(), ...evt };
|
||||
lastHeartbeat = enriched;
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(enriched);
|
||||
} catch {
|
||||
// Ignore listener errors so heartbeat flow stays robust.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function onHeartbeatEvent(listener: (evt: HeartbeatEventPayload) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function getLastHeartbeatEvent(): HeartbeatEventPayload | null {
|
||||
return lastHeartbeat;
|
||||
}
|
||||
31
src/heartbeat/heartbeat-text.test.ts
Normal file
31
src/heartbeat/heartbeat-text.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HEARTBEAT_TOKEN,
|
||||
isHeartbeatContentEffectivelyEmpty,
|
||||
stripHeartbeatToken,
|
||||
} from "./heartbeat-text.js";
|
||||
|
||||
describe("heartbeat-text", () => {
|
||||
it("treats comment-only heartbeat files as empty", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# title\n\n- [ ]\n")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("\n# note\n")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("check disk health")).toBe(false);
|
||||
});
|
||||
|
||||
it("strips plain token responses", () => {
|
||||
const result = stripHeartbeatToken(HEARTBEAT_TOKEN, { mode: "heartbeat" });
|
||||
expect(result.shouldSkip).toBe(true);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("keeps substantial content around token in heartbeat mode", () => {
|
||||
const longTail = "Potential issue detected: disk usage is 92% on /Users";
|
||||
const result = stripHeartbeatToken(`${HEARTBEAT_TOKEN} ${longTail}`, {
|
||||
mode: "heartbeat",
|
||||
maxAckChars: 10,
|
||||
});
|
||||
|
||||
expect(result.shouldSkip).toBe(false);
|
||||
expect(result.text).toContain("disk usage");
|
||||
});
|
||||
});
|
||||
117
src/heartbeat/heartbeat-text.ts
Normal file
117
src/heartbeat/heartbeat-text.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
|
||||
|
||||
export const HEARTBEAT_PROMPT =
|
||||
"Read heartbeat.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.";
|
||||
|
||||
export const DEFAULT_HEARTBEAT_EVERY = "30m";
|
||||
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
||||
|
||||
export function isHeartbeatContentEffectivelyEmpty(
|
||||
content: string | undefined | null,
|
||||
): boolean {
|
||||
if (content === undefined || content === null || typeof content !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (/^#+(\s|$)/.test(trimmed)) continue;
|
||||
if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatPrompt(raw?: string): string {
|
||||
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||
return trimmed || HEARTBEAT_PROMPT;
|
||||
}
|
||||
|
||||
export type StripHeartbeatMode = "heartbeat" | "message";
|
||||
|
||||
function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
|
||||
let text = raw.trim();
|
||||
if (!text) return { text: "", didStrip: false };
|
||||
if (!text.includes(HEARTBEAT_TOKEN)) return { text, didStrip: false };
|
||||
|
||||
let didStrip = false;
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
const next = text.trim();
|
||||
if (next.startsWith(HEARTBEAT_TOKEN)) {
|
||||
text = next.slice(HEARTBEAT_TOKEN.length).trimStart();
|
||||
didStrip = true;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (next.endsWith(HEARTBEAT_TOKEN)) {
|
||||
text = next.slice(0, Math.max(0, next.length - HEARTBEAT_TOKEN.length)).trimEnd();
|
||||
didStrip = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: text.replace(/\s+/g, " ").trim(),
|
||||
didStrip,
|
||||
};
|
||||
}
|
||||
|
||||
export function stripHeartbeatToken(
|
||||
raw?: string,
|
||||
opts: { mode?: StripHeartbeatMode; maxAckChars?: number } = {},
|
||||
): { shouldSkip: boolean; text: string; didStrip: boolean } {
|
||||
if (!raw) return { shouldSkip: true, text: "", didStrip: false };
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { shouldSkip: true, text: "", didStrip: false };
|
||||
|
||||
const mode = opts.mode ?? "message";
|
||||
const maxAckCharsRaw = opts.maxAckChars;
|
||||
const maxAckChars = Math.max(
|
||||
0,
|
||||
typeof maxAckCharsRaw === "number" && Number.isFinite(maxAckCharsRaw)
|
||||
? maxAckCharsRaw
|
||||
: DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
);
|
||||
|
||||
const stripMarkup = (text: string) =>
|
||||
text
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/^[*`~_]+/, "")
|
||||
.replace(/[*`~_]+$/, "");
|
||||
|
||||
const normalized = stripMarkup(trimmed);
|
||||
const hasToken =
|
||||
trimmed.includes(HEARTBEAT_TOKEN) || normalized.includes(HEARTBEAT_TOKEN);
|
||||
if (!hasToken) {
|
||||
return { shouldSkip: false, text: trimmed, didStrip: false };
|
||||
}
|
||||
|
||||
const strippedOriginal = stripTokenAtEdges(trimmed);
|
||||
const strippedNormalized = stripTokenAtEdges(normalized);
|
||||
const picked =
|
||||
strippedOriginal.didStrip && strippedOriginal.text
|
||||
? strippedOriginal
|
||||
: strippedNormalized;
|
||||
|
||||
if (!picked.didStrip) {
|
||||
return { shouldSkip: false, text: trimmed, didStrip: false };
|
||||
}
|
||||
|
||||
if (!picked.text) {
|
||||
return { shouldSkip: true, text: "", didStrip: true };
|
||||
}
|
||||
|
||||
const rest = picked.text.trim();
|
||||
if (mode === "heartbeat" && rest.length <= maxAckChars) {
|
||||
return { shouldSkip: true, text: "", didStrip: true };
|
||||
}
|
||||
|
||||
return { shouldSkip: false, text: rest, didStrip: true };
|
||||
}
|
||||
47
src/heartbeat/heartbeat-wake.test.ts
Normal file
47
src/heartbeat/heartbeat-wake.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
hasPendingHeartbeatWake,
|
||||
requestHeartbeatNow,
|
||||
setHeartbeatWakeHandler,
|
||||
} from "./heartbeat-wake.js";
|
||||
|
||||
describe("heartbeat-wake", () => {
|
||||
afterEach(() => {
|
||||
setHeartbeatWakeHandler(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("coalesces multiple wake requests into one run", async () => {
|
||||
vi.useFakeTimers();
|
||||
const handler = vi.fn(async () => ({ status: "ran" as const, durationMs: 1 }));
|
||||
|
||||
setHeartbeatWakeHandler(handler);
|
||||
requestHeartbeatNow({ reason: "a" });
|
||||
requestHeartbeatNow({ reason: "b" });
|
||||
requestHeartbeatNow({ reason: "c" });
|
||||
|
||||
expect(hasPendingHeartbeatWake()).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries when requests are in flight", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const handler = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "skipped" as const, reason: "requests-in-flight" })
|
||||
.mockResolvedValueOnce({ status: "ran" as const, durationMs: 3 });
|
||||
|
||||
setHeartbeatWakeHandler(handler);
|
||||
requestHeartbeatNow({ reason: "retry-case" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
73
src/heartbeat/heartbeat-wake.ts
Normal file
73
src/heartbeat/heartbeat-wake.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
export type HeartbeatRunResult =
|
||||
| { status: "ran"; durationMs: number }
|
||||
| { status: "skipped"; reason: string }
|
||||
| { status: "failed"; reason: string };
|
||||
|
||||
export type HeartbeatWakeHandler = (opts: {
|
||||
reason?: string;
|
||||
}) => Promise<HeartbeatRunResult>;
|
||||
|
||||
let handler: HeartbeatWakeHandler | null = null;
|
||||
let pendingReason: string | null = null;
|
||||
let scheduled = false;
|
||||
let running = false;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
const DEFAULT_COALESCE_MS = 250;
|
||||
const DEFAULT_RETRY_MS = 1000;
|
||||
|
||||
function schedule(coalesceMs: number): void {
|
||||
if (timer) return;
|
||||
timer = setTimeout(async () => {
|
||||
timer = null;
|
||||
scheduled = false;
|
||||
const active = handler;
|
||||
if (!active) return;
|
||||
|
||||
if (running) {
|
||||
scheduled = true;
|
||||
schedule(coalesceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = pendingReason;
|
||||
pendingReason = null;
|
||||
running = true;
|
||||
try {
|
||||
const result = reason ? await active({ reason }) : await active({});
|
||||
if (result.status === "skipped" && result.reason === "requests-in-flight") {
|
||||
pendingReason = reason ?? "retry";
|
||||
schedule(DEFAULT_RETRY_MS);
|
||||
}
|
||||
} catch {
|
||||
pendingReason = reason ?? "retry";
|
||||
schedule(DEFAULT_RETRY_MS);
|
||||
} finally {
|
||||
running = false;
|
||||
if (pendingReason || scheduled) {
|
||||
schedule(coalesceMs);
|
||||
}
|
||||
}
|
||||
}, coalesceMs);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export function setHeartbeatWakeHandler(next: HeartbeatWakeHandler | null): void {
|
||||
handler = next;
|
||||
if (handler && pendingReason) {
|
||||
schedule(DEFAULT_COALESCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function requestHeartbeatNow(opts?: { reason?: string; coalesceMs?: number }): void {
|
||||
pendingReason = opts?.reason ?? pendingReason ?? "requested";
|
||||
schedule(opts?.coalesceMs ?? DEFAULT_COALESCE_MS);
|
||||
}
|
||||
|
||||
export function hasHeartbeatWakeHandler(): boolean {
|
||||
return handler !== null;
|
||||
}
|
||||
|
||||
export function hasPendingHeartbeatWake(): boolean {
|
||||
return pendingReason !== null || Boolean(timer) || scheduled;
|
||||
}
|
||||
45
src/heartbeat/index.ts
Normal file
45
src/heartbeat/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
export {
|
||||
emitHeartbeatEvent,
|
||||
getLastHeartbeatEvent,
|
||||
onHeartbeatEvent,
|
||||
resolveIndicatorType,
|
||||
type HeartbeatEventPayload,
|
||||
type HeartbeatIndicatorType,
|
||||
} from "./heartbeat-events.js";
|
||||
|
||||
export {
|
||||
hasHeartbeatWakeHandler,
|
||||
hasPendingHeartbeatWake,
|
||||
requestHeartbeatNow,
|
||||
setHeartbeatWakeHandler,
|
||||
type HeartbeatRunResult,
|
||||
type HeartbeatWakeHandler,
|
||||
} from "./heartbeat-wake.js";
|
||||
|
||||
export {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
DEFAULT_HEARTBEAT_EVERY,
|
||||
HEARTBEAT_PROMPT,
|
||||
HEARTBEAT_TOKEN,
|
||||
isHeartbeatContentEffectivelyEmpty,
|
||||
resolveHeartbeatPrompt,
|
||||
stripHeartbeatToken,
|
||||
type StripHeartbeatMode,
|
||||
} from "./heartbeat-text.js";
|
||||
|
||||
export {
|
||||
drainSystemEvents,
|
||||
enqueueSystemEvent,
|
||||
hasSystemEvents,
|
||||
peekSystemEvents,
|
||||
resetSystemEventsForTest,
|
||||
type SystemEvent,
|
||||
} from "./system-events.js";
|
||||
|
||||
export {
|
||||
runHeartbeatOnce,
|
||||
setHeartbeatsEnabled,
|
||||
startHeartbeatRunner,
|
||||
type HeartbeatConfig,
|
||||
type HeartbeatRunner,
|
||||
} from "./runner.js";
|
||||
74
src/heartbeat/runner.test.ts
Normal file
74
src/heartbeat/runner.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runHeartbeatOnce, setHeartbeatsEnabled } from "./runner.js";
|
||||
|
||||
type StubAgent = {
|
||||
closed: boolean;
|
||||
sessionId: string;
|
||||
ensureInitialized: () => Promise<void>;
|
||||
getMessages: () => Array<any>;
|
||||
write: (content: string) => void;
|
||||
waitForIdle: () => Promise<void>;
|
||||
getHeartbeatConfig: () => { prompt?: string; ackMaxChars?: number; enabled?: boolean };
|
||||
getPendingWrites: () => number;
|
||||
getProfileDir: () => string | undefined;
|
||||
};
|
||||
|
||||
function createStubAgent(opts?: {
|
||||
profileDir?: string;
|
||||
replyText?: string;
|
||||
heartbeatEnabled?: boolean;
|
||||
}): StubAgent {
|
||||
const messages: Array<any> = [];
|
||||
const replyText = opts?.replyText ?? "HEARTBEAT_OK";
|
||||
|
||||
return {
|
||||
closed: false,
|
||||
sessionId: "test-session",
|
||||
ensureInitialized: async () => {},
|
||||
getMessages: () => messages,
|
||||
write: (content: string) => {
|
||||
messages.push({ role: "user", content });
|
||||
messages.push({ role: "assistant", content: [{ type: "text", text: replyText }] });
|
||||
},
|
||||
waitForIdle: async () => {},
|
||||
getHeartbeatConfig: () =>
|
||||
typeof opts?.heartbeatEnabled === "boolean"
|
||||
? { enabled: opts.heartbeatEnabled }
|
||||
: {},
|
||||
getPendingWrites: () => 0,
|
||||
getProfileDir: () => opts?.profileDir,
|
||||
};
|
||||
}
|
||||
|
||||
describe("heartbeat runner", () => {
|
||||
afterEach(() => {
|
||||
setHeartbeatsEnabled(true);
|
||||
});
|
||||
|
||||
it("skips when no agent is available", async () => {
|
||||
const result = await runHeartbeatOnce({ agent: null });
|
||||
expect(result).toEqual({ status: "skipped", reason: "disabled" });
|
||||
});
|
||||
|
||||
it("skips when heartbeat file is effectively empty", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heartbeat-test-"));
|
||||
try {
|
||||
await writeFile(path.join(dir, "heartbeat.md"), "# keep empty\n", "utf-8");
|
||||
const agent = createStubAgent({ profileDir: dir });
|
||||
const result = await runHeartbeatOnce({ agent: agent as any });
|
||||
expect(result).toEqual({ status: "skipped", reason: "empty-heartbeat-file" });
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("runs and returns ran for heartbeat acknowledgements", async () => {
|
||||
const agent = createStubAgent({ replyText: "HEARTBEAT_OK" });
|
||||
const result = await runHeartbeatOnce({ agent: agent as any, reason: "manual" });
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
});
|
||||
});
|
||||
321
src/heartbeat/runner.ts
Normal file
321
src/heartbeat/runner.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AsyncAgent } from "../agent/async-agent.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
DEFAULT_HEARTBEAT_EVERY,
|
||||
isHeartbeatContentEffectivelyEmpty,
|
||||
resolveHeartbeatPrompt,
|
||||
stripHeartbeatToken,
|
||||
} from "./heartbeat-text.js";
|
||||
import {
|
||||
emitHeartbeatEvent,
|
||||
resolveIndicatorType,
|
||||
type HeartbeatEventPayload,
|
||||
} from "./heartbeat-events.js";
|
||||
import {
|
||||
setHeartbeatWakeHandler,
|
||||
requestHeartbeatNow,
|
||||
type HeartbeatRunResult,
|
||||
type HeartbeatWakeHandler,
|
||||
} from "./heartbeat-wake.js";
|
||||
import { drainSystemEvents } from "./system-events.js";
|
||||
|
||||
export type HeartbeatConfig = {
|
||||
enabled?: boolean;
|
||||
every?: string;
|
||||
prompt?: string;
|
||||
ackMaxChars?: number;
|
||||
};
|
||||
|
||||
export type HeartbeatRunner = {
|
||||
stop: () => void;
|
||||
updateConfig: () => void;
|
||||
};
|
||||
|
||||
type RunnerDeps = {
|
||||
getAgent: () => AsyncAgent | null;
|
||||
nowMs?: () => number;
|
||||
logger?: Pick<Console, "info" | "warn" | "error">;
|
||||
};
|
||||
|
||||
const HEARTBEAT_FILENAME = "heartbeat.md";
|
||||
const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
|
||||
let heartbeatsEnabled = true;
|
||||
|
||||
function resolveDurationMs(raw: string | undefined): number | null {
|
||||
if (!raw) return DEFAULT_INTERVAL_MS;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return DEFAULT_INTERVAL_MS;
|
||||
|
||||
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhd])$/i);
|
||||
if (match) {
|
||||
const num = Number.parseFloat(match[1]!);
|
||||
const unit = match[2]!.toLowerCase();
|
||||
const unitMs: Record<string, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
const ms = unitMs[unit];
|
||||
if (!Number.isFinite(num) || !ms) return null;
|
||||
const value = Math.floor(num * ms);
|
||||
return value > 0 ? value : null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const value = Number.parseInt(trimmed, 10);
|
||||
return value > 0 ? value : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractMessageText(message: AgentMessage | undefined): string {
|
||||
if (!message) return "";
|
||||
const raw = (message as { content?: unknown }).content;
|
||||
if (typeof raw === "string") return raw;
|
||||
if (!Array.isArray(raw)) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const block of raw) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
function getHeartbeatConfig(agent: AsyncAgent | null): HeartbeatConfig {
|
||||
const cfg = agent?.getHeartbeatConfig();
|
||||
if (!cfg) return {};
|
||||
|
||||
const out: HeartbeatConfig = {};
|
||||
if (typeof cfg.enabled === "boolean") out.enabled = cfg.enabled;
|
||||
if (typeof cfg.every === "string") out.every = cfg.every;
|
||||
if (typeof cfg.prompt === "string") out.prompt = cfg.prompt;
|
||||
if (typeof cfg.ackMaxChars === "number" && Number.isFinite(cfg.ackMaxChars)) {
|
||||
out.ackMaxChars = cfg.ackMaxChars;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveHeartbeatIntervalMs(agent: AsyncAgent | null): number {
|
||||
const cfg = getHeartbeatConfig(agent);
|
||||
return resolveDurationMs(cfg.every ?? DEFAULT_HEARTBEAT_EVERY) ?? DEFAULT_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function resolveSessionKey(agent: AsyncAgent): string {
|
||||
return agent.sessionId;
|
||||
}
|
||||
|
||||
async function isHeartbeatFileEmpty(agent: AsyncAgent): Promise<boolean> {
|
||||
const profileDir = agent.getProfileDir();
|
||||
if (!profileDir) return false;
|
||||
const heartbeatPath = path.join(profileDir, HEARTBEAT_FILENAME);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(heartbeatPath, "utf-8");
|
||||
return isHeartbeatContentEffectivelyEmpty(content);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setHeartbeatsEnabled(enabled: boolean): void {
|
||||
heartbeatsEnabled = enabled;
|
||||
}
|
||||
|
||||
export async function runHeartbeatOnce(opts: {
|
||||
agent: AsyncAgent | null;
|
||||
reason?: string;
|
||||
nowMs?: () => number;
|
||||
}): Promise<HeartbeatRunResult> {
|
||||
const startedAt = opts.nowMs?.() ?? Date.now();
|
||||
const agent = opts.agent;
|
||||
|
||||
if (!heartbeatsEnabled) {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
|
||||
if (!agent || agent.closed) {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
|
||||
const cfg = getHeartbeatConfig(agent);
|
||||
if (cfg.enabled === false) {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
|
||||
if (agent.getPendingWrites() > 0) {
|
||||
return { status: "skipped", reason: "requests-in-flight" };
|
||||
}
|
||||
|
||||
try {
|
||||
const isExecEvent = opts.reason === "exec-event";
|
||||
if (!isExecEvent && (await isHeartbeatFileEmpty(agent))) {
|
||||
emitHeartbeatEvent({
|
||||
status: "skipped",
|
||||
reason: "empty-heartbeat-file",
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return { status: "skipped", reason: "empty-heartbeat-file" };
|
||||
}
|
||||
|
||||
await agent.ensureInitialized();
|
||||
const beforeMessages = agent.getMessages();
|
||||
const sessionKey = resolveSessionKey(agent);
|
||||
const pendingEvents = drainSystemEvents(sessionKey);
|
||||
|
||||
const basePrompt = resolveHeartbeatPrompt(cfg.prompt);
|
||||
const prompt = pendingEvents.length
|
||||
? `${basePrompt}\n\nSystem events:\n${pendingEvents.map((line) => `- ${line}`).join("\n")}`
|
||||
: basePrompt;
|
||||
|
||||
agent.write(prompt);
|
||||
await agent.waitForIdle();
|
||||
|
||||
const afterMessages = agent.getMessages();
|
||||
const appended = afterMessages.slice(beforeMessages.length);
|
||||
const assistant = [...appended]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
const text = extractMessageText(assistant);
|
||||
|
||||
if (!text.trim()) {
|
||||
const okEmptyEvent: Omit<HeartbeatEventPayload, "ts"> = {
|
||||
status: "ok-empty",
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
if (opts.reason) okEmptyEvent.reason = opts.reason;
|
||||
const indicator = resolveIndicatorType("ok-empty");
|
||||
if (indicator) okEmptyEvent.indicatorType = indicator;
|
||||
emitHeartbeatEvent(okEmptyEvent);
|
||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
}
|
||||
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
mode: "heartbeat",
|
||||
maxAckChars: cfg.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
});
|
||||
|
||||
if (stripped.shouldSkip) {
|
||||
const okTokenEvent: Omit<HeartbeatEventPayload, "ts"> = {
|
||||
status: "ok-token",
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
if (opts.reason) okTokenEvent.reason = opts.reason;
|
||||
const indicator = resolveIndicatorType("ok-token");
|
||||
if (indicator) okTokenEvent.indicatorType = indicator;
|
||||
emitHeartbeatEvent(okTokenEvent);
|
||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
}
|
||||
|
||||
const sentEvent: Omit<HeartbeatEventPayload, "ts"> = {
|
||||
status: "sent",
|
||||
preview: stripped.text.slice(0, 200),
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
if (opts.reason) sentEvent.reason = opts.reason;
|
||||
const sentIndicator = resolveIndicatorType("sent");
|
||||
if (sentIndicator) sentEvent.indicatorType = sentIndicator;
|
||||
emitHeartbeatEvent(sentEvent);
|
||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
const failedEvent: Omit<HeartbeatEventPayload, "ts"> = {
|
||||
status: "failed",
|
||||
reason,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
const failedIndicator = resolveIndicatorType("failed");
|
||||
if (failedIndicator) failedEvent.indicatorType = failedIndicator;
|
||||
emitHeartbeatEvent(failedEvent);
|
||||
return { status: "failed", reason };
|
||||
}
|
||||
}
|
||||
|
||||
export function startHeartbeatRunner(deps: RunnerDeps): HeartbeatRunner {
|
||||
const logger = deps.logger ?? console;
|
||||
const nowMs = deps.nowMs ?? (() => Date.now());
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let stopped = false;
|
||||
let intervalMs = resolveHeartbeatIntervalMs(deps.getAgent());
|
||||
let nextDueAtMs = nowMs() + intervalMs;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNext = () => {
|
||||
if (stopped) return;
|
||||
clearTimer();
|
||||
|
||||
const delay = Math.max(0, nextDueAtMs - nowMs());
|
||||
timer = setTimeout(() => {
|
||||
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
||||
}, delay);
|
||||
timer.unref?.();
|
||||
};
|
||||
|
||||
const run: HeartbeatWakeHandler = async (params) => {
|
||||
const reason = params.reason;
|
||||
const agent = deps.getAgent();
|
||||
|
||||
if (reason === "interval") {
|
||||
const now = nowMs();
|
||||
if (now < nextDueAtMs) {
|
||||
return { status: "skipped", reason: "not-due" };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runHeartbeatOnce(
|
||||
reason
|
||||
? {
|
||||
agent,
|
||||
reason,
|
||||
nowMs,
|
||||
}
|
||||
: {
|
||||
agent,
|
||||
nowMs,
|
||||
},
|
||||
);
|
||||
|
||||
const activeAgent = deps.getAgent();
|
||||
intervalMs = resolveHeartbeatIntervalMs(activeAgent);
|
||||
nextDueAtMs = nowMs() + intervalMs;
|
||||
scheduleNext();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
setHeartbeatWakeHandler(run);
|
||||
scheduleNext();
|
||||
logger.info?.("[Heartbeat] runner started");
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearTimer();
|
||||
setHeartbeatWakeHandler(null);
|
||||
logger.info?.("[Heartbeat] runner stopped");
|
||||
},
|
||||
updateConfig: () => {
|
||||
const agent = deps.getAgent();
|
||||
intervalMs = resolveHeartbeatIntervalMs(agent);
|
||||
nextDueAtMs = nowMs() + intervalMs;
|
||||
scheduleNext();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { HeartbeatEventPayload };
|
||||
51
src/heartbeat/system-events.ts
Normal file
51
src/heartbeat/system-events.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export type SystemEvent = { text: string; ts: number };
|
||||
|
||||
const MAX_EVENTS = 20;
|
||||
const queues = new Map<string, SystemEvent[]>();
|
||||
|
||||
function normalizeSessionKey(key: string | undefined): string {
|
||||
const trimmed = typeof key === "string" ? key.trim() : "";
|
||||
if (!trimmed) {
|
||||
throw new Error("system events require a sessionKey");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function enqueueSystemEvent(text: string, opts: { sessionKey: string }): void {
|
||||
const sessionKey = normalizeSessionKey(opts.sessionKey);
|
||||
const cleaned = text.trim();
|
||||
if (!cleaned) return;
|
||||
|
||||
const list = queues.get(sessionKey) ?? [];
|
||||
const previous = list[list.length - 1];
|
||||
if (previous?.text === cleaned) {
|
||||
return;
|
||||
}
|
||||
|
||||
list.push({ text: cleaned, ts: Date.now() });
|
||||
if (list.length > MAX_EVENTS) {
|
||||
list.splice(0, list.length - MAX_EVENTS);
|
||||
}
|
||||
queues.set(sessionKey, list);
|
||||
}
|
||||
|
||||
export function drainSystemEvents(sessionKey: string): string[] {
|
||||
const key = normalizeSessionKey(sessionKey);
|
||||
const list = queues.get(key) ?? [];
|
||||
queues.delete(key);
|
||||
return list.map((entry) => entry.text);
|
||||
}
|
||||
|
||||
export function peekSystemEvents(sessionKey: string): string[] {
|
||||
const key = normalizeSessionKey(sessionKey);
|
||||
return (queues.get(key) ?? []).map((entry) => entry.text);
|
||||
}
|
||||
|
||||
export function hasSystemEvents(sessionKey: string): boolean {
|
||||
const key = normalizeSessionKey(sessionKey);
|
||||
return (queues.get(key)?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function resetSystemEventsForTest(): void {
|
||||
queues.clear();
|
||||
}
|
||||
|
|
@ -88,6 +88,27 @@ describe("ExecApprovalManager", () => {
|
|||
expect(result.decision).toBe("deny");
|
||||
});
|
||||
|
||||
it("keeps approval pending indefinitely when timeoutMs is -1", async () => {
|
||||
const promise = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
command: "cmd",
|
||||
riskLevel: "needs-review",
|
||||
riskReasons: [],
|
||||
timeoutMs: -1,
|
||||
});
|
||||
|
||||
const request = sendToClient.mock.calls[0]![1];
|
||||
expect(request.expiresAtMs).toBe(-1);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(manager.pendingCount).toBe(1);
|
||||
|
||||
manager.resolveApproval(request.approvalId, "allow-once");
|
||||
const result = await promise;
|
||||
expect(result.approved).toBe(true);
|
||||
expect(result.decision).toBe("allow-once");
|
||||
});
|
||||
|
||||
it("honors askFallback full on timeout", async () => {
|
||||
const promise = manager.requestApproval({
|
||||
agentId: "agent-1",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { DEFAULT_APPROVAL_TIMEOUT_MS } from "../agent/tools/exec-approval-types.
|
|||
|
||||
interface PendingEntry {
|
||||
resolve: (result: ApprovalResult) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
timer: NodeJS.Timeout | null;
|
||||
request: ExecApprovalRequest;
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ export class ExecApprovalManager {
|
|||
}): Promise<ApprovalResult> {
|
||||
const approvalId = uuidv7();
|
||||
const timeoutMs = params.timeoutMs ?? this.defaultTimeoutMs;
|
||||
const expiresAtMs = Date.now() + timeoutMs;
|
||||
const expiresAtMs = timeoutMs >= 0 ? Date.now() + timeoutMs : -1;
|
||||
|
||||
const request: ExecApprovalRequest = {
|
||||
approvalId,
|
||||
|
|
@ -65,19 +65,21 @@ export class ExecApprovalManager {
|
|||
};
|
||||
|
||||
return new Promise<ApprovalResult>((resolve) => {
|
||||
// Timeout: follow askFallback (default: fail-closed)
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pending.has(approvalId)) {
|
||||
this.pending.delete(approvalId);
|
||||
const fallback = params.askFallback ?? "deny";
|
||||
const decision =
|
||||
fallback === "full" ||
|
||||
(fallback === "allowlist" && params.allowlistSatisfied)
|
||||
? "allow-once"
|
||||
: "deny";
|
||||
resolve({ approved: decision !== "deny", decision });
|
||||
}
|
||||
}, timeoutMs);
|
||||
// Timeout: follow askFallback (default: fail-closed). Skip if timeoutMs is -1 (no timeout).
|
||||
const timer = timeoutMs >= 0
|
||||
? setTimeout(() => {
|
||||
if (this.pending.has(approvalId)) {
|
||||
this.pending.delete(approvalId);
|
||||
const fallback = params.askFallback ?? "deny";
|
||||
const decision =
|
||||
fallback === "full" ||
|
||||
(fallback === "allowlist" && params.allowlistSatisfied)
|
||||
? "allow-once"
|
||||
: "deny";
|
||||
resolve({ approved: decision !== "deny", decision });
|
||||
}
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
this.pending.set(approvalId, { resolve, timer, request });
|
||||
|
||||
|
|
@ -86,7 +88,7 @@ export class ExecApprovalManager {
|
|||
this.sendToClient(params.agentId, request);
|
||||
} catch (err) {
|
||||
// If sending fails, auto-deny (fail-closed)
|
||||
clearTimeout(timer);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.pending.delete(approvalId);
|
||||
console.error(`[ExecApprovalManager] Failed to send approval request: ${err}`);
|
||||
resolve({ approved: false, decision: "deny" });
|
||||
|
|
@ -102,7 +104,7 @@ export class ExecApprovalManager {
|
|||
const entry = this.pending.get(approvalId);
|
||||
if (!entry) return false;
|
||||
|
||||
clearTimeout(entry.timer);
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
this.pending.delete(approvalId);
|
||||
|
||||
entry.resolve({
|
||||
|
|
@ -120,7 +122,7 @@ export class ExecApprovalManager {
|
|||
cancelPending(agentId: string): void {
|
||||
for (const [id, entry] of this.pending) {
|
||||
if (entry.request.agentId === agentId) {
|
||||
clearTimeout(entry.timer);
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
this.pending.delete(id);
|
||||
entry.resolve({ approved: false, decision: "deny" });
|
||||
}
|
||||
|
|
|
|||
66
src/hub/heartbeat-filter.test.ts
Normal file
66
src/hub/heartbeat-filter.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractAssistantEventText,
|
||||
isHeartbeatAckEvent,
|
||||
} from "./heartbeat-filter.js";
|
||||
|
||||
describe("heartbeat-filter", () => {
|
||||
it("extracts text from string content", () => {
|
||||
const event = {
|
||||
message: {
|
||||
content: " HEARTBEAT_OK ",
|
||||
},
|
||||
};
|
||||
expect(extractAssistantEventText(event)).toBe("HEARTBEAT_OK");
|
||||
});
|
||||
|
||||
it("extracts text from content blocks", () => {
|
||||
const event = {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "line 1" },
|
||||
{ type: "thinking", thinking: "hidden" },
|
||||
{ type: "text", text: "line 2" },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(extractAssistantEventText(event)).toBe("line 1 line 2");
|
||||
});
|
||||
|
||||
it("treats pure heartbeat token as ack", () => {
|
||||
const event = {
|
||||
message: {
|
||||
content: [{ type: "text", text: "HEARTBEAT_OK" }],
|
||||
},
|
||||
};
|
||||
expect(isHeartbeatAckEvent(event)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats marked-up heartbeat token as ack", () => {
|
||||
const event = {
|
||||
message: {
|
||||
content: [{ type: "text", text: "**HEARTBEAT_OK**" }],
|
||||
},
|
||||
};
|
||||
expect(isHeartbeatAckEvent(event)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not suppress real alert text", () => {
|
||||
const event = {
|
||||
message: {
|
||||
content: [{ type: "text", text: "Reminder: go downstairs now." }],
|
||||
},
|
||||
};
|
||||
expect(isHeartbeatAckEvent(event)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not suppress token plus extra content", () => {
|
||||
const event = {
|
||||
message: {
|
||||
content: [{ type: "text", text: "HEARTBEAT_OK Reminder: check inbox." }],
|
||||
},
|
||||
};
|
||||
expect(isHeartbeatAckEvent(event)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
43
src/hub/heartbeat-filter.ts
Normal file
43
src/hub/heartbeat-filter.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { stripHeartbeatToken } from "../heartbeat/index.js";
|
||||
|
||||
function collapseWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract assistant text from an agent stream event.
|
||||
* Supports both string and rich content-array message shapes.
|
||||
*/
|
||||
export function extractAssistantEventText(event: unknown): string {
|
||||
if (!event || typeof event !== "object") return "";
|
||||
const message = (event as { message?: unknown }).message;
|
||||
if (!message || typeof message !== "object") return "";
|
||||
const content = (message as { content?: unknown }).content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
return collapseWhitespace(content);
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
return collapseWhitespace(parts.join("\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for pure heartbeat ACK payloads (e.g. "HEARTBEAT_OK").
|
||||
* Messages that include any extra text are not suppressed.
|
||||
*/
|
||||
export function isHeartbeatAckEvent(event: unknown): boolean {
|
||||
const text = extractAssistantEventText(event);
|
||||
if (!text) return false;
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
return stripped.shouldSkip && stripped.didStrip;
|
||||
}
|
||||
|
||||
209
src/hub/hub.ts
209
src/hub/hub.ts
|
|
@ -22,6 +22,9 @@ import { createListAgentsHandler } from "./rpc/handlers/list-agents.js";
|
|||
import { createCreateAgentHandler } from "./rpc/handlers/create-agent.js";
|
||||
import { createDeleteAgentHandler } from "./rpc/handlers/delete-agent.js";
|
||||
import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js";
|
||||
import { createGetLastHeartbeatHandler } from "./rpc/handlers/get-last-heartbeat.js";
|
||||
import { createSetHeartbeatsHandler } from "./rpc/handlers/set-heartbeats.js";
|
||||
import { createWakeHeartbeatHandler } from "./rpc/handlers/wake-heartbeat.js";
|
||||
import { DeviceStore, type DeviceMeta } from "./device-store.js";
|
||||
import { createVerifyHandler } from "./rpc/handlers/verify.js";
|
||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||
|
|
@ -31,15 +34,34 @@ import { addAllowlistEntry, recordAllowlistUse, matchAllowlist } from "../agent/
|
|||
import type { ExecApprovalCallback, ExecApprovalConfig, ApprovalResult, ExecApprovalRequest } from "../agent/tools/exec-approval-types.js";
|
||||
import { readProfileConfig, writeProfileConfig } from "../agent/profile/storage.js";
|
||||
import { ChannelManager, initChannels } from "../channels/index.js";
|
||||
import { getCronService, shutdownCronService, executeCronJob } from "../cron/index.js";
|
||||
import {
|
||||
getLastHeartbeatEvent,
|
||||
onHeartbeatEvent,
|
||||
requestHeartbeatNow,
|
||||
runHeartbeatOnce,
|
||||
setHeartbeatsEnabled,
|
||||
startHeartbeatRunner,
|
||||
type HeartbeatEventPayload,
|
||||
type HeartbeatRunResult,
|
||||
type HeartbeatRunner,
|
||||
} from "../heartbeat/index.js";
|
||||
import { enqueueSystemEvent } from "../heartbeat/system-events.js";
|
||||
import { isHeartbeatAckEvent } from "./heartbeat-filter.js";
|
||||
|
||||
export class Hub {
|
||||
private readonly agents = new Map<string, AsyncAgent>();
|
||||
private readonly agentSenders = new Map<string, string>();
|
||||
private readonly agentStreamIds = new Map<string, string>();
|
||||
private readonly agentStreamCounters = new Map<string, number>();
|
||||
private readonly pendingAssistantStarts = new Map<string, { agentId: string; event: unknown }>();
|
||||
private readonly suppressedStreamAgents = new Set<string>();
|
||||
private readonly localApprovalHandlers = new Map<string, (payload: ExecApprovalRequest) => void>();
|
||||
private readonly rpc: RpcDispatcher;
|
||||
private readonly approvalManager: ExecApprovalManager;
|
||||
private readonly heartbeatListeners = new Set<(event: HeartbeatEventPayload) => void>();
|
||||
private heartbeatRunner: HeartbeatRunner | null = null;
|
||||
private heartbeatUnsubscribe: (() => void) | null = null;
|
||||
private client: GatewayClient;
|
||||
readonly deviceStore: DeviceStore;
|
||||
private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | null = null;
|
||||
|
|
@ -78,6 +100,9 @@ export class Hub {
|
|||
this.rpc.register("createAgent", createCreateAgentHandler(this));
|
||||
this.rpc.register("deleteAgent", createDeleteAgentHandler(this));
|
||||
this.rpc.register("updateGateway", createUpdateGatewayHandler(this));
|
||||
this.rpc.register("last-heartbeat", createGetLastHeartbeatHandler(this));
|
||||
this.rpc.register("set-heartbeats", createSetHeartbeatsHandler(this));
|
||||
this.rpc.register("wake-heartbeat", createWakeHeartbeatHandler(this));
|
||||
|
||||
// Initialize exec approval manager
|
||||
this.approvalManager = new ExecApprovalManager((agentId, payload) => {
|
||||
|
|
@ -102,6 +127,10 @@ export class Hub {
|
|||
// Restore subagent registry from persistent state
|
||||
initSubagentRegistry();
|
||||
|
||||
// Initialize and start cron service
|
||||
this.initCronService();
|
||||
this.initHeartbeatService();
|
||||
|
||||
this.client = this.createClient(this.url);
|
||||
this.client.connect();
|
||||
this.restoreAgents();
|
||||
|
|
@ -118,6 +147,42 @@ export class Hub {
|
|||
});
|
||||
}
|
||||
|
||||
/** Initialize cron service with executor */
|
||||
private initCronService(): void {
|
||||
const cronService = getCronService();
|
||||
cronService.setExecutor(executeCronJob);
|
||||
cronService.start().catch((err) => {
|
||||
console.error("[Hub] Failed to start cron service:", err);
|
||||
});
|
||||
console.log("[Hub] Cron service initialized");
|
||||
}
|
||||
|
||||
/** Initialize heartbeat runner + event fanout. */
|
||||
private initHeartbeatService(): void {
|
||||
this.heartbeatRunner = startHeartbeatRunner({
|
||||
getAgent: () => this.getDefaultAgent(),
|
||||
logger: console,
|
||||
});
|
||||
|
||||
this.heartbeatUnsubscribe = onHeartbeatEvent((event) => {
|
||||
for (const listener of this.heartbeatListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// Keep fanout resilient against listener errors.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[Hub] Heartbeat service initialized");
|
||||
}
|
||||
|
||||
private getDefaultAgent(): AsyncAgent | null {
|
||||
const first = this.listAgents()[0];
|
||||
if (!first) return null;
|
||||
return this.getAgent(first) ?? null;
|
||||
}
|
||||
|
||||
/** Restore agents from persistent storage */
|
||||
private restoreAgents(): void {
|
||||
const records = loadAgentRecords();
|
||||
|
|
@ -279,6 +344,7 @@ export class Hub {
|
|||
|
||||
// Internally consume agent output (AgentEvent stream + error Messages)
|
||||
void this.consumeAgent(agent);
|
||||
this.heartbeatRunner?.updateConfig();
|
||||
|
||||
console.log(`Agent created: ${agent.sessionId}`);
|
||||
return agent;
|
||||
|
|
@ -313,6 +379,14 @@ export class Hub {
|
|||
this.agentStreamIds.delete(agentId);
|
||||
}
|
||||
|
||||
private clearPendingAssistantStarts(agentId: string): void {
|
||||
for (const [streamId, pending] of this.pendingAssistantStarts) {
|
||||
if (pending.agentId === agentId) {
|
||||
this.pendingAssistantStarts.delete(streamId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Internally read agent output and send via Gateway */
|
||||
private async consumeAgent(agent: AsyncAgent): Promise<void> {
|
||||
for await (const item of agent.read()) {
|
||||
|
|
@ -327,6 +401,20 @@ export class Hub {
|
|||
content: item.content,
|
||||
});
|
||||
} else {
|
||||
const suppressForAgent = this.suppressedStreamAgents.has(agent.sessionId);
|
||||
|
||||
// Suppress all user-visible stream events during silent heartbeat runs.
|
||||
if (suppressForAgent) {
|
||||
if (item.type === "message_start") {
|
||||
this.beginStream(agent.sessionId, item);
|
||||
} else if (item.type === "message_end") {
|
||||
const streamId = this.getActiveStreamId(agent.sessionId, item);
|
||||
this.pendingAssistantStarts.delete(streamId);
|
||||
this.endStream(agent.sessionId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Passthrough events: forward with synthetic streamId (no stream tracking)
|
||||
const isPassthroughEvent =
|
||||
item.type === "compaction_start" || item.type === "compaction_end" || item.type === "agent_error";
|
||||
|
|
@ -348,18 +436,55 @@ export class Hub {
|
|||
|| item.type === "tool_execution_end";
|
||||
if (!shouldForward) continue;
|
||||
|
||||
if (item.type === "message_start") {
|
||||
this.beginStream(agent.sessionId, item);
|
||||
const isAssistantMessageEvent =
|
||||
item.type === "message_start" || item.type === "message_update" || item.type === "message_end";
|
||||
|
||||
// Delay assistant message_start forwarding until we see content.
|
||||
// This lets us suppress pure HEARTBEAT_OK acknowledgements end-to-end.
|
||||
if (isAssistantMessageEvent && isAssistantMessage) {
|
||||
if (item.type === "message_start") {
|
||||
const streamId = this.beginStream(agent.sessionId, item);
|
||||
this.pendingAssistantStarts.set(streamId, { agentId: agent.sessionId, event: item });
|
||||
continue;
|
||||
}
|
||||
|
||||
const streamId = this.getActiveStreamId(agent.sessionId, item);
|
||||
const isHeartbeatAck = isHeartbeatAckEvent(item);
|
||||
if (isHeartbeatAck) {
|
||||
if (item.type === "message_end") {
|
||||
this.pendingAssistantStarts.delete(streamId);
|
||||
this.endStream(agent.sessionId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const pendingStart = this.pendingAssistantStarts.get(streamId);
|
||||
if (pendingStart) {
|
||||
this.client.send(targetDeviceId, StreamAction, {
|
||||
streamId,
|
||||
agentId: agent.sessionId,
|
||||
event: pendingStart.event,
|
||||
});
|
||||
this.pendingAssistantStarts.delete(streamId);
|
||||
}
|
||||
|
||||
this.client.send(targetDeviceId, StreamAction, {
|
||||
streamId,
|
||||
agentId: agent.sessionId,
|
||||
event: item,
|
||||
});
|
||||
if (item.type === "message_end") {
|
||||
this.endStream(agent.sessionId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const streamId = this.getActiveStreamId(agent.sessionId, item);
|
||||
this.client.send(targetDeviceId, StreamAction, {
|
||||
streamId,
|
||||
agentId: agent.sessionId,
|
||||
event: item,
|
||||
});
|
||||
if (item.type === "message_end") {
|
||||
this.endStream(agent.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -422,8 +547,8 @@ export class Hub {
|
|||
// No profile config, use defaults
|
||||
}
|
||||
|
||||
const security = config.security ?? "allowlist";
|
||||
const ask = config.ask ?? "on-miss";
|
||||
const security = config.security ?? "full";
|
||||
const ask = config.ask ?? "off";
|
||||
|
||||
// Security: deny blocks everything
|
||||
if (security === "deny") {
|
||||
|
|
@ -507,6 +632,63 @@ export class Hub {
|
|||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
/** Subscribe heartbeat state updates. Returns unsubscribe callback. */
|
||||
onHeartbeatEvent(callback: (event: HeartbeatEventPayload) => void): () => void {
|
||||
this.heartbeatListeners.add(callback);
|
||||
return () => {
|
||||
this.heartbeatListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
/** Get latest heartbeat event payload. */
|
||||
getLastHeartbeat(): HeartbeatEventPayload | null {
|
||||
return getLastHeartbeatEvent();
|
||||
}
|
||||
|
||||
/** Enable/disable heartbeat runner globally. */
|
||||
setHeartbeatsEnabled(enabled: boolean): void {
|
||||
setHeartbeatsEnabled(enabled);
|
||||
this.heartbeatRunner?.updateConfig();
|
||||
}
|
||||
|
||||
/** Enqueue a heartbeat wake request. */
|
||||
requestHeartbeatNow(opts?: { reason?: string }): void {
|
||||
requestHeartbeatNow(opts);
|
||||
}
|
||||
|
||||
/** Run heartbeat immediately using the current default agent. */
|
||||
async runHeartbeatOnce(opts?: { reason?: string }): Promise<HeartbeatRunResult> {
|
||||
const agent = this.getDefaultAgent();
|
||||
const reason = opts?.reason;
|
||||
const shouldSuppressStreams = reason === "manual";
|
||||
if (shouldSuppressStreams && agent) {
|
||||
this.suppressedStreamAgents.add(agent.sessionId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (reason) {
|
||||
return runHeartbeatOnce({
|
||||
agent,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
return runHeartbeatOnce({
|
||||
agent,
|
||||
});
|
||||
} finally {
|
||||
if (shouldSuppressStreams && agent) {
|
||||
this.suppressedStreamAgents.delete(agent.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Enqueue a system event for a specific agent or the default agent. */
|
||||
enqueueSystemEvent(text: string, opts?: { agentId?: string }): void {
|
||||
const agentId = opts?.agentId ?? this.listAgents()[0];
|
||||
if (!agentId) return;
|
||||
enqueueSystemEvent(text, { sessionKey: agentId });
|
||||
}
|
||||
|
||||
closeAgent(id: string): boolean {
|
||||
const agent = this.agents.get(id);
|
||||
if (!agent) return false;
|
||||
|
|
@ -516,8 +698,11 @@ export class Hub {
|
|||
this.agentSenders.delete(id);
|
||||
this.agentStreamIds.delete(id);
|
||||
this.agentStreamCounters.delete(id);
|
||||
this.clearPendingAssistantStarts(id);
|
||||
this.suppressedStreamAgents.delete(id);
|
||||
this.localApprovalHandlers.delete(id);
|
||||
removeAgentRecord(id);
|
||||
this.heartbeatRunner?.updateConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -525,6 +710,14 @@ export class Hub {
|
|||
// Stop all channel connections
|
||||
this.channelManager.stopAll();
|
||||
|
||||
// Stop cron service
|
||||
shutdownCronService();
|
||||
this.heartbeatRunner?.stop();
|
||||
this.heartbeatRunner = null;
|
||||
this.heartbeatUnsubscribe?.();
|
||||
this.heartbeatUnsubscribe = null;
|
||||
this.heartbeatListeners.clear();
|
||||
|
||||
// Finalize subagent registry before closing agents
|
||||
shutdownSubagentRegistry();
|
||||
|
||||
|
|
@ -534,6 +727,8 @@ export class Hub {
|
|||
this.agentSenders.delete(id);
|
||||
this.agentStreamIds.delete(id);
|
||||
this.agentStreamCounters.delete(id);
|
||||
this.clearPendingAssistantStarts(id);
|
||||
this.suppressedStreamAgents.delete(id);
|
||||
this.localApprovalHandlers.delete(id);
|
||||
}
|
||||
this.client.disconnect();
|
||||
|
|
|
|||
9
src/hub/rpc/handlers/get-last-heartbeat.ts
Normal file
9
src/hub/rpc/handlers/get-last-heartbeat.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { RpcHandler } from "../dispatcher.js";
|
||||
|
||||
interface HubLike {
|
||||
getLastHeartbeat(): unknown;
|
||||
}
|
||||
|
||||
export function createGetLastHeartbeatHandler(hub: HubLike): RpcHandler {
|
||||
return () => hub.getLastHeartbeat();
|
||||
}
|
||||
18
src/hub/rpc/handlers/set-heartbeats.ts
Normal file
18
src/hub/rpc/handlers/set-heartbeats.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { RpcHandler } from "../dispatcher.js";
|
||||
import { RpcError } from "../dispatcher.js";
|
||||
|
||||
interface HubLike {
|
||||
setHeartbeatsEnabled(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export function createSetHeartbeatsHandler(hub: HubLike): RpcHandler {
|
||||
return (params) => {
|
||||
const enabled = (params as { enabled?: unknown } | undefined)?.enabled;
|
||||
if (typeof enabled !== "boolean") {
|
||||
throw new RpcError("INVALID_REQUEST", "enabled (boolean) is required");
|
||||
}
|
||||
|
||||
hub.setHeartbeatsEnabled(enabled);
|
||||
return { ok: true, enabled };
|
||||
};
|
||||
}
|
||||
14
src/hub/rpc/handlers/wake-heartbeat.ts
Normal file
14
src/hub/rpc/handlers/wake-heartbeat.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { RpcHandler } from "../dispatcher.js";
|
||||
|
||||
interface HubLike {
|
||||
requestHeartbeatNow(opts?: { reason?: string }): void;
|
||||
}
|
||||
|
||||
export function createWakeHeartbeatHandler(hub: HubLike): RpcHandler {
|
||||
return (params) => {
|
||||
const reasonRaw = (params as { reason?: unknown } | undefined)?.reason;
|
||||
const reason = typeof reasonRaw === "string" ? reasonRaw.trim() : "";
|
||||
hub.requestHeartbeatNow({ reason: reason || "manual" });
|
||||
return { ok: true };
|
||||
};
|
||||
}
|
||||
|
|
@ -5,3 +5,6 @@ export { createListAgentsHandler } from "./handlers/list-agents.js";
|
|||
export { createCreateAgentHandler } from "./handlers/create-agent.js";
|
||||
export { createDeleteAgentHandler } from "./handlers/delete-agent.js";
|
||||
export { createUpdateGatewayHandler } from "./handlers/update-gateway.js";
|
||||
export { createGetLastHeartbeatHandler } from "./handlers/get-last-heartbeat.js";
|
||||
export { createSetHeartbeatsHandler } from "./handlers/set-heartbeats.js";
|
||||
export { createWakeHeartbeatHandler } from "./handlers/wake-heartbeat.js";
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export * from "./agent/index.js";
|
|||
export * from "./gateway/index.js";
|
||||
export * from "./client/index.js";
|
||||
export * from "./shared/index.js";
|
||||
export * from "./heartbeat/index.js";
|
||||
|
|
|
|||
0
~/.super-multica/channels.json5
Normal file
0
~/.super-multica/channels.json5
Normal file
Loading…
Add table
Add a link
Reference in a new issue