refactor: rename session_state to agent_list and add subscriptions
This commit is contained in:
@@ -64,3 +64,9 @@ Run `npx expo-doctor` to diagnose version mismatches and native module issues.
|
|||||||
|
|
||||||
|
|
||||||
**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.**
|
**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.**
|
||||||
|
|
||||||
|
## NEVER DO THESE THINGS
|
||||||
|
|
||||||
|
- **NEVER restart the Paseo daemon/server** - The daemon is running in Tmux and managed by the user. Restarting it disrupts active sessions, loses state, and breaks workflows. If there's a connectivity issue, investigate the cause - do not restart.
|
||||||
|
- **NEVER kill or restart processes in Tmux** without explicit user permission
|
||||||
|
- **NEVER assume a timeout means the service needs restarting** - Timeouts can be transient network issues, not service failures
|
||||||
|
|||||||
@@ -616,7 +616,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingTop: theme.spacing[2],
|
paddingTop: theme.spacing[2],
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
@@ -626,7 +625,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
marginHorizontal: -theme.spacing[2],
|
|
||||||
marginTop: theme.spacing[2],
|
marginTop: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
@@ -643,6 +641,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
sectionContainer: {
|
sectionContainer: {
|
||||||
|
marginHorizontal: theme.spacing[2],
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
sectionDragging: {
|
sectionDragging: {
|
||||||
@@ -701,7 +700,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
agentItem: {
|
agentItem: {
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
marginHorizontal: -theme.spacing[2],
|
marginLeft: theme.spacing[1],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -78,11 +78,23 @@ function SearchInput({
|
|||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
}: SearchInputProps): ReactElement {
|
}: SearchInputProps): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus && IS_WEB && inputRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [autoFocus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.searchInputContainer}>
|
<View style={styles.searchInputContainer}>
|
||||||
<Search size={16} color={theme.colors.foregroundMuted} />
|
<Search size={16} color={theme.colors.foregroundMuted} />
|
||||||
<InputComponent
|
<InputComponent
|
||||||
|
ref={inputRef as any}
|
||||||
// @ts-expect-error - outlineStyle is web-only
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -91,7 +103,6 @@ function SearchInput({
|
|||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoFocus={autoFocus}
|
|
||||||
onSubmitEditing={onSubmitEditing}
|
onSubmitEditing={onSubmitEditing}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ import type { ReactNode } from "react";
|
|||||||
import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context";
|
import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context";
|
||||||
|
|
||||||
export type ConnectionState =
|
export type ConnectionState =
|
||||||
| { status: "idle"; lastError: null; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: false }
|
| { status: "idle"; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: false }
|
||||||
| { status: "connecting"; lastError: null; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: boolean }
|
| { status: "connecting"; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
|
||||||
| { status: "online"; lastError: null; lastOnlineAt: string; sessionReady: boolean; hasEverReceivedSessionState: boolean }
|
| { status: "online"; lastError: null; lastOnlineAt: string; agentListReady: boolean; hasEverReceivedAgentList: boolean }
|
||||||
| { status: "offline"; lastError: string | null; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: boolean }
|
| { status: "offline"; lastError: string | null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
|
||||||
| { status: "error"; lastError: string; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: boolean };
|
| { status: "error"; lastError: string; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean };
|
||||||
|
|
||||||
export type ConnectionStatus = ConnectionState["status"];
|
export type ConnectionStatus = ConnectionState["status"];
|
||||||
|
|
||||||
type ConnectionStateUpdate =
|
type ConnectionStateUpdate =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
| { status: "connecting"; lastOnlineAt?: string | null }
|
| { status: "connecting"; lastOnlineAt?: string | null }
|
||||||
| { status: "online"; lastOnlineAt: string; sessionReady?: boolean }
|
| { status: "online"; lastOnlineAt: string; agentListReady?: boolean }
|
||||||
| { status: "offline"; lastError?: string | null; lastOnlineAt?: string | null }
|
| { status: "offline"; lastError?: string | null; lastOnlineAt?: string | null }
|
||||||
| { status: "error"; lastError: string; lastOnlineAt?: string | null };
|
| { status: "error"; lastError: string; lastOnlineAt?: string | null };
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ function createDefaultConnectionState(): ConnectionState {
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
lastError: null,
|
lastError: null,
|
||||||
lastOnlineAt: null,
|
lastOnlineAt: null,
|
||||||
sessionReady: false,
|
agentListReady: false,
|
||||||
hasEverReceivedSessionState: false,
|
hasEverReceivedAgentList: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,43 +50,43 @@ function resolveNextConnectionState(
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
lastError: null,
|
lastError: null,
|
||||||
lastOnlineAt: existing.lastOnlineAt,
|
lastOnlineAt: existing.lastOnlineAt,
|
||||||
sessionReady: false,
|
agentListReady: false,
|
||||||
hasEverReceivedSessionState: false,
|
hasEverReceivedAgentList: false,
|
||||||
};
|
};
|
||||||
case "connecting":
|
case "connecting":
|
||||||
return {
|
return {
|
||||||
status: "connecting",
|
status: "connecting",
|
||||||
lastError: null,
|
lastError: null,
|
||||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||||
sessionReady: false,
|
agentListReady: false,
|
||||||
hasEverReceivedSessionState: existing.hasEverReceivedSessionState ?? false,
|
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
|
||||||
};
|
};
|
||||||
case "online":
|
case "online":
|
||||||
const currentSessionReady = update.sessionReady ?? (existing.status === "online" ? existing.sessionReady : false);
|
const currentAgentListReady = update.agentListReady ?? (existing.status === "online" ? existing.agentListReady : false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: "online",
|
status: "online",
|
||||||
lastError: null,
|
lastError: null,
|
||||||
lastOnlineAt: update.lastOnlineAt,
|
lastOnlineAt: update.lastOnlineAt,
|
||||||
sessionReady: currentSessionReady,
|
agentListReady: currentAgentListReady,
|
||||||
hasEverReceivedSessionState:
|
hasEverReceivedAgentList:
|
||||||
currentSessionReady || existing.hasEverReceivedSessionState || false,
|
currentAgentListReady || existing.hasEverReceivedAgentList || false,
|
||||||
};
|
};
|
||||||
case "offline":
|
case "offline":
|
||||||
return {
|
return {
|
||||||
status: "offline",
|
status: "offline",
|
||||||
lastError: update.lastError ?? null,
|
lastError: update.lastError ?? null,
|
||||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||||
sessionReady: false,
|
agentListReady: false,
|
||||||
hasEverReceivedSessionState: existing.hasEverReceivedSessionState ?? false,
|
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
|
||||||
};
|
};
|
||||||
case "error":
|
case "error":
|
||||||
return {
|
return {
|
||||||
status: "error",
|
status: "error",
|
||||||
lastError: update.lastError,
|
lastError: update.lastError,
|
||||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||||
sessionReady: false,
|
agentListReady: false,
|
||||||
hasEverReceivedSessionState: existing.hasEverReceivedSessionState ?? false,
|
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ function normalizeAgentSnapshot(
|
|||||||
attentionReason: snapshot.attentionReason ?? null,
|
attentionReason: snapshot.attentionReason ?? null,
|
||||||
attentionTimestamp,
|
attentionTimestamp,
|
||||||
archivedAt,
|
archivedAt,
|
||||||
|
labels: snapshot.labels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +412,7 @@ export function SessionProvider({
|
|||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
const hasRequestedInitialSnapshotRef = useRef(false);
|
const hasRequestedInitialSnapshotRef = useRef(false);
|
||||||
|
const agentUpdatesSubscriptionIdRef = useRef<string | null>(null);
|
||||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -674,6 +676,15 @@ export function SessionProvider({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectionSnapshot.isConnected) {
|
if (!connectionSnapshot.isConnected) {
|
||||||
hasRequestedInitialSnapshotRef.current = false;
|
hasRequestedInitialSnapshotRef.current = false;
|
||||||
|
const subscriptionId = agentUpdatesSubscriptionIdRef.current;
|
||||||
|
if (subscriptionId && client) {
|
||||||
|
try {
|
||||||
|
client.unsubscribeAgentUpdates(subscriptionId);
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agentUpdatesSubscriptionIdRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasRequestedInitialSnapshotRef.current) {
|
if (hasRequestedInitialSnapshotRef.current) {
|
||||||
@@ -687,15 +698,21 @@ export function SessionProvider({
|
|||||||
|
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
const requestSessionState = () => {
|
const requestAgentList = () => {
|
||||||
console.log(
|
console.log(
|
||||||
`[Session] Requesting session_state (attempt ${retryCount + 1}/${
|
`[Session] Requesting agent_list (attempt ${retryCount + 1}/${
|
||||||
MAX_RETRIES + 1
|
MAX_RETRIES + 1
|
||||||
})`,
|
})`,
|
||||||
{ serverId }
|
{ serverId }
|
||||||
);
|
);
|
||||||
void client
|
void client
|
||||||
.requestSessionState();
|
.requestAgentList({ filter: { labels: { ui: "true" } } });
|
||||||
|
if (!agentUpdatesSubscriptionIdRef.current) {
|
||||||
|
agentUpdatesSubscriptionIdRef.current = client.subscribeAgentUpdates({
|
||||||
|
subscriptionId: `app:${serverId}`,
|
||||||
|
filter: { labels: { ui: "true" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionStateTimeoutRef.current) {
|
if (sessionStateTimeoutRef.current) {
|
||||||
clearTimeout(sessionStateTimeoutRef.current);
|
clearTimeout(sessionStateTimeoutRef.current);
|
||||||
@@ -705,7 +722,7 @@ export function SessionProvider({
|
|||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
console.warn(
|
console.warn(
|
||||||
`[Session] session_state timeout, retrying in ${RETRY_DELAY_MS}ms`,
|
`[Session] agent_list timeout, retrying in ${RETRY_DELAY_MS}ms`,
|
||||||
{
|
{
|
||||||
serverId,
|
serverId,
|
||||||
attempt: retryCount,
|
attempt: retryCount,
|
||||||
@@ -714,11 +731,11 @@ export function SessionProvider({
|
|||||||
);
|
);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
requestSessionState();
|
requestAgentList();
|
||||||
}, RETRY_DELAY_MS);
|
}, RETRY_DELAY_MS);
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
`[Session] session_state failed after ${MAX_RETRIES} retries`,
|
`[Session] agent_list failed after ${MAX_RETRIES} retries`,
|
||||||
{ serverId }
|
{ serverId }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -726,13 +743,13 @@ export function SessionProvider({
|
|||||||
updateConnectionStatus(serverId, {
|
updateConnectionStatus(serverId, {
|
||||||
status: "online",
|
status: "online",
|
||||||
lastOnlineAt: new Date().toISOString(),
|
lastOnlineAt: new Date().toISOString(),
|
||||||
sessionReady: true,
|
agentListReady: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, TIMEOUT_MS);
|
}, TIMEOUT_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
requestSessionState();
|
requestAgentList();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (sessionStateTimeoutRef.current) {
|
if (sessionStateTimeoutRef.current) {
|
||||||
@@ -744,10 +761,10 @@ export function SessionProvider({
|
|||||||
|
|
||||||
// Daemon message handlers - directly update Zustand store
|
// Daemon message handlers - directly update Zustand store
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[Session] Setting up session_state listener for", serverId);
|
console.log("[Session] Setting up agent_list listener for", serverId);
|
||||||
|
|
||||||
const unsubSessionState = client.on("session_state", (message) => {
|
const unsubAgentList = client.on("agent_list", (message) => {
|
||||||
if (message.type !== "session_state") return;
|
if (message.type !== "agent_list") return;
|
||||||
|
|
||||||
if (sessionStateTimeoutRef.current) {
|
if (sessionStateTimeoutRef.current) {
|
||||||
clearTimeout(sessionStateTimeoutRef.current);
|
clearTimeout(sessionStateTimeoutRef.current);
|
||||||
@@ -757,7 +774,7 @@ export function SessionProvider({
|
|||||||
const { agents: agentsList } = message.payload;
|
const { agents: agentsList } = message.payload;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"[Session] ✅ Received session_state:",
|
"[Session] ✅ Received agent_list:",
|
||||||
agentsList.length,
|
agentsList.length,
|
||||||
"agents"
|
"agents"
|
||||||
);
|
);
|
||||||
@@ -849,16 +866,57 @@ export function SessionProvider({
|
|||||||
updateConnectionStatus(serverId, {
|
updateConnectionStatus(serverId, {
|
||||||
status: "online",
|
status: "online",
|
||||||
lastOnlineAt: new Date().toISOString(),
|
lastOnlineAt: new Date().toISOString(),
|
||||||
sessionReady: true,
|
agentListReady: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubAgentState = client.on("agent_state", (message) => {
|
const unsubAgentUpdate = client.on("agent_update", (message) => {
|
||||||
if (message.type !== "agent_state") return;
|
if (message.type !== "agent_update") return;
|
||||||
const snapshot = message.payload;
|
const update = message.payload;
|
||||||
const agent = normalizeAgentSnapshot(snapshot, serverId);
|
|
||||||
|
|
||||||
console.log("[Session] Agent state update:", agent.id, agent.status);
|
if (update.kind === "remove") {
|
||||||
|
const agentId = update.agentId;
|
||||||
|
previousAgentStatusRef.current.delete(agentId);
|
||||||
|
|
||||||
|
setAgents(serverId, (prev) => {
|
||||||
|
if (!prev.has(agentId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(agentId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingPermissions(serverId, (prev) => {
|
||||||
|
if (prev.size === 0) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [key, pending] of Array.from(next.entries())) {
|
||||||
|
if (pending.agentId === agentId) {
|
||||||
|
next.delete(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
setQueuedMessages(serverId, (prev) => {
|
||||||
|
if (!prev.has(agentId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(agentId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = normalizeAgentSnapshot(update.agent, serverId);
|
||||||
|
|
||||||
|
console.log("[Session] Agent update:", agent.id, agent.status);
|
||||||
|
|
||||||
setAgents(serverId, (prev) => {
|
setAgents(serverId, (prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -967,7 +1025,7 @@ export function SessionProvider({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
||||||
// cascading rerenders. The agent_state handler updates agent.lastActivityAt
|
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
||||||
// on status changes, which is sufficient for sorting and display purposes.
|
// on status changes, which is sufficient for sorting and display purposes.
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1462,8 +1520,8 @@ export function SessionProvider({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubSessionState();
|
unsubAgentList();
|
||||||
unsubAgentState();
|
unsubAgentUpdate();
|
||||||
unsubAgentStream();
|
unsubAgentStream();
|
||||||
unsubAgentStreamSnapshot();
|
unsubAgentStreamSnapshot();
|
||||||
unsubStatus();
|
unsubStatus();
|
||||||
@@ -1834,7 +1892,7 @@ export function SessionProvider({
|
|||||||
[encodeImages, serverId, client, setAgentStreamTail]
|
[encodeImages, serverId, client, setAgentStreamTail]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the ref updated so the agent_state handler can call it
|
// Keep the ref updated so the agent_update handler can call it
|
||||||
sendAgentMessageRef.current = sendAgentMessage;
|
sendAgentMessageRef.current = sendAgentMessage;
|
||||||
|
|
||||||
const cancelAgentRun = useCallback(
|
const cancelAgentRun = useCallback(
|
||||||
@@ -1934,6 +1992,7 @@ export function SessionProvider({
|
|||||||
}
|
}
|
||||||
return client.createAgent({
|
return client.createAgent({
|
||||||
config,
|
config,
|
||||||
|
labels: { ui: "true" },
|
||||||
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
|
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
|
||||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||||
...(git ? { git } : {}),
|
...(git ? { git } : {}),
|
||||||
@@ -2166,9 +2225,9 @@ export function SessionProvider({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
client.requestSessionState();
|
client.requestAgentList({ filter: { labels: { ui: "true" } } });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[Session] Failed to refresh session:", error);
|
console.error("[Session] Failed to refresh agent list:", error);
|
||||||
}
|
}
|
||||||
}, [client, serverId]);
|
}, [client, serverId]);
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
|||||||
attentionReason: agent.attentionReason,
|
attentionReason: agent.attentionReason,
|
||||||
attentionTimestamp: agent.attentionTimestamp,
|
attentionTimestamp: agent.attentionTimestamp,
|
||||||
archivedAt: agent.archivedAt,
|
archivedAt: agent.archivedAt,
|
||||||
|
labels: agent.labels,
|
||||||
};
|
};
|
||||||
allAgents.push(nextAgent);
|
allAgents.push(nextAgent);
|
||||||
}
|
}
|
||||||
@@ -103,23 +104,23 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
|||||||
const isConnecting = Array.from(connectionStates.entries()).some(([id, c]) => {
|
const isConnecting = Array.from(connectionStates.entries()).some(([id, c]) => {
|
||||||
const shortId = id.substring(0, 20);
|
const shortId = id.substring(0, 20);
|
||||||
|
|
||||||
// First-time connection (never received session state)
|
// First-time connection (never received agent list)
|
||||||
if (c.status === 'connecting' && !c.hasEverReceivedSessionState) {
|
if (c.status === 'connecting' && !c.hasEverReceivedAgentList) {
|
||||||
connectingReasons.push(`${shortId}: first-time connecting`);
|
connectingReasons.push(`${shortId}: first-time connecting`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (c.status === 'online' && !c.hasEverReceivedSessionState) {
|
if (c.status === 'online' && !c.hasEverReceivedAgentList) {
|
||||||
connectingReasons.push(`${shortId}: online but no session_state yet`);
|
connectingReasons.push(`${shortId}: online but no agent_list yet`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconnecting (have received session state before)
|
// Reconnecting (have received agent list before)
|
||||||
if (c.status === 'connecting' && c.hasEverReceivedSessionState) {
|
if (c.status === 'connecting' && c.hasEverReceivedAgentList) {
|
||||||
connectingReasons.push(`${shortId}: reconnecting`);
|
connectingReasons.push(`${shortId}: reconnecting`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (c.status === 'online' && !c.sessionReady && c.hasEverReceivedSessionState) {
|
if (c.status === 'online' && !c.agentListReady && c.hasEverReceivedAgentList) {
|
||||||
connectingReasons.push(`${shortId}: online but sessionReady=false (waiting for session_state)`);
|
connectingReasons.push(`${shortId}: online but agentListReady=false (waiting for agent_list)`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,8 +140,8 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
|||||||
const connectionStatesArray = Array.from(connectionStates.entries()).map(([id, state]) => ({
|
const connectionStatesArray = Array.from(connectionStates.entries()).map(([id, state]) => ({
|
||||||
id: id.substring(0, 20) + (id.length > 20 ? '...' : ''),
|
id: id.substring(0, 20) + (id.length > 20 ? '...' : ''),
|
||||||
status: state.status,
|
status: state.status,
|
||||||
sessionReady: state.sessionReady,
|
agentListReady: state.agentListReady,
|
||||||
hasEverReceivedSessionState: state.hasEverReceivedSessionState,
|
hasEverReceivedAgentList: state.hasEverReceivedAgentList,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('[useAggregatedAgents] States:', {
|
console.log('[useAggregatedAgents] States:', {
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ function AgentScreenContent({
|
|||||||
title: "Agent",
|
title: "Agent",
|
||||||
cwd: ".",
|
cwd: ".",
|
||||||
model: null,
|
model: null,
|
||||||
|
labels: {},
|
||||||
};
|
};
|
||||||
}, [resolvedAgentId, serverId, shouldUseOptimisticStream]);
|
}, [resolvedAgentId, serverId, shouldUseOptimisticStream]);
|
||||||
|
|
||||||
|
|||||||
@@ -273,13 +273,20 @@ export function DraftAgentScreen({
|
|||||||
if (!selectedServerId || !sessionAgents) {
|
if (!selectedServerId || !sessionAgents) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const uniquePaths = new Set<string>();
|
const pathLastCreated = new Map<string, Date>();
|
||||||
sessionAgents.forEach((agent) => {
|
sessionAgents.forEach((agent) => {
|
||||||
if (agent.cwd) {
|
if (agent.cwd && !agent.cwd.includes(".paseo/worktrees")) {
|
||||||
uniquePaths.add(agent.cwd);
|
const existing = pathLastCreated.get(agent.cwd);
|
||||||
|
if (!existing || agent.createdAt > existing) {
|
||||||
|
pathLastCreated.set(agent.cwd, agent.createdAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return Array.from(uniquePaths).sort();
|
return Array.from(pathLastCreated.keys()).sort((a, b) => {
|
||||||
|
const aTime = pathLastCreated.get(a)!.getTime();
|
||||||
|
const bTime = pathLastCreated.get(b)!.getTime();
|
||||||
|
return bTime - aTime;
|
||||||
|
});
|
||||||
}, [selectedServerId, sessionAgents]);
|
}, [selectedServerId, sessionAgents]);
|
||||||
|
|
||||||
const sessionClient = useSessionStore((state) =>
|
const sessionClient = useSessionStore((state) =>
|
||||||
@@ -600,6 +607,7 @@ export function DraftAgentScreen({
|
|||||||
title: "New agent",
|
title: "New agent",
|
||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
|
labels: {},
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
machine.tag,
|
machine.tag,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export interface Agent {
|
|||||||
attentionReason?: "finished" | "error" | "permission" | null;
|
attentionReason?: "finished" | "error" | "permission" | null;
|
||||||
attentionTimestamp?: Date | null;
|
attentionTimestamp?: Date | null;
|
||||||
archivedAt?: Date | null;
|
archivedAt?: Date | null;
|
||||||
|
labels: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExplorerEntryKind = "file" | "directory";
|
export type ExplorerEntryKind = "file" | "directory";
|
||||||
@@ -838,6 +839,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
requiresAttention: agent.requiresAttention ?? false,
|
requiresAttention: agent.requiresAttention ?? false,
|
||||||
attentionReason: agent.attentionReason ?? null,
|
attentionReason: agent.attentionReason ?? null,
|
||||||
attentionTimestamp: agent.attentionTimestamp ?? null,
|
attentionTimestamp: agent.attentionTimestamp ?? null,
|
||||||
|
labels: agent.labels,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import type { AgentLifecycleStatus } from "@server/server/agent/agent-manager";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
export interface AgentDirectoryEntry {
|
export type AgentDirectoryEntry = Pick<
|
||||||
id: string;
|
Agent,
|
||||||
serverId: string;
|
| "id"
|
||||||
title: string | null;
|
| "serverId"
|
||||||
status: AgentLifecycleStatus;
|
| "title"
|
||||||
lastActivityAt: Date;
|
| "status"
|
||||||
cwd: string;
|
| "lastActivityAt"
|
||||||
provider: AgentProvider;
|
| "cwd"
|
||||||
requiresAttention?: boolean;
|
| "provider"
|
||||||
attentionReason?: "finished" | "error" | "permission" | null;
|
| "requiresAttention"
|
||||||
attentionTimestamp?: Date | null;
|
| "attentionReason"
|
||||||
archivedAt?: Date | null;
|
| "attentionTimestamp"
|
||||||
}
|
| "archivedAt"
|
||||||
|
| "labels"
|
||||||
|
>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Command } from 'commander'
|
import { Command } from 'commander'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
import { createAgentCommand } from './commands/agent/index.js'
|
import { createAgentCommand } from './commands/agent/index.js'
|
||||||
import { createDaemonCommand } from './commands/daemon/index.js'
|
import { createDaemonCommand } from './commands/daemon/index.js'
|
||||||
import { createPermitCommand } from './commands/permit/index.js'
|
import { createPermitCommand } from './commands/permit/index.js'
|
||||||
@@ -13,6 +15,7 @@ import { runInspectCommand } from './commands/agent/inspect.js'
|
|||||||
import { runWaitCommand } from './commands/agent/wait.js'
|
import { runWaitCommand } from './commands/agent/wait.js'
|
||||||
import { runAttachCommand } from './commands/agent/attach.js'
|
import { runAttachCommand } from './commands/agent/attach.js'
|
||||||
import { withOutput } from './output/index.js'
|
import { withOutput } from './output/index.js'
|
||||||
|
import { runSelfIdBridge } from '@paseo/server/self-id-bridge'
|
||||||
|
|
||||||
const VERSION = '0.1.0'
|
const VERSION = '0.1.0'
|
||||||
|
|
||||||
@@ -142,5 +145,20 @@ export function createCli(): Command {
|
|||||||
// Worktree commands
|
// Worktree commands
|
||||||
program.addCommand(createWorktreeCommand())
|
program.addCommand(createWorktreeCommand())
|
||||||
|
|
||||||
|
// Self-ID bridge command (for internal use by agents to call set_title/set_branch)
|
||||||
|
program
|
||||||
|
.command('self-id-bridge')
|
||||||
|
.description('Stdio-to-HTTP bridge for Agent Self-ID MCP (internal use)')
|
||||||
|
.option('--socket <path>', 'Unix socket path', join(process.env.PASEO_HOME ?? join(homedir(), '.paseo'), 'self-id-mcp.sock'))
|
||||||
|
.option('--agent-id <id>', 'Caller agent ID')
|
||||||
|
.option('--debug', 'Enable debug logging to stderr')
|
||||||
|
.action(async (options) => {
|
||||||
|
await runSelfIdBridge({
|
||||||
|
socketPath: options.socket,
|
||||||
|
agentId: options.agentId,
|
||||||
|
debug: options.debug,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return program
|
return program
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ export async function runArchiveCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait a moment for the session state to be populated
|
// Wait a moment for the agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -118,10 +118,10 @@ export async function runAttachCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait for session state to be populated
|
// Wait for agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -217,10 +217,10 @@ export async function runInspectCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait a moment for the session state to be populated
|
// Wait a moment for the agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ export async function runLogsCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait for session state to be populated
|
// Wait for agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ export async function runLsCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request and wait for session state to get agent information
|
// Request and wait for agent list
|
||||||
await client.waitForSessionState()
|
await client.waitForAgentList()
|
||||||
|
|
||||||
let agents = client.listAgents()
|
let agents = client.listAgents()
|
||||||
|
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ export async function runModeCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait a moment for the session state to be populated
|
// Wait a moment for the agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -140,10 +140,10 @@ export async function runSendCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait a moment for the session state to be populated
|
// Wait a moment for the agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ export async function runStopCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait a moment for the session state to be populated
|
// Wait a moment for the agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
let agents = client.listAgents()
|
let agents = client.listAgents()
|
||||||
|
|||||||
@@ -121,10 +121,10 @@ export async function runWaitCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait a moment for the session state to be populated
|
// Wait a moment for the agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ export async function runStatusCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait a moment for the session state to be populated
|
// Wait a moment for the agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export async function runAllowCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export async function runDenyCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ export async function runLsCommand(options: PermitLsOptions, _command: Command):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait a moment for the session state to be populated
|
// Wait a moment for the agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -63,10 +63,10 @@ export async function runLsCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request session state to get agent information
|
// Request agent list
|
||||||
client.requestSessionState()
|
client.requestAgentList()
|
||||||
|
|
||||||
// Wait a moment for the session state to be populated
|
// Wait a moment for the agent list to be populated
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
const agents = client.listAgents()
|
const agents = client.listAgents()
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([connectPromise, timeoutPromise])
|
await Promise.race([connectPromise, timeoutPromise])
|
||||||
|
client.subscribeAgentUpdates({ subscriptionId: `cli:${process.pid}` })
|
||||||
return client
|
return client
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.close().catch(() => {})
|
await client.close().catch(() => {})
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/server/exports.ts",
|
".": "./src/server/exports.ts",
|
||||||
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
|
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts",
|
||||||
|
"./self-id-bridge": "./src/self-id-bridge/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
|
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
|
||||||
|
|||||||
@@ -110,14 +110,20 @@ export type ConnectionState =
|
|||||||
| { status: "disconnected"; reason?: string };
|
| { status: "disconnected"; reason?: string };
|
||||||
|
|
||||||
export type DaemonEvent =
|
export type DaemonEvent =
|
||||||
| { type: "agent_state"; agentId: string; payload: AgentSnapshotPayload }
|
| {
|
||||||
|
type: "agent_update";
|
||||||
|
agentId: string;
|
||||||
|
payload:
|
||||||
|
| { kind: "upsert"; agent: AgentSnapshotPayload }
|
||||||
|
| { kind: "remove"; agentId: string };
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "agent_stream";
|
type: "agent_stream";
|
||||||
agentId: string;
|
agentId: string;
|
||||||
event: AgentStreamEventPayload;
|
event: AgentStreamEventPayload;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
| { type: "session_state"; agents: AgentSnapshotPayload[] }
|
| { type: "agent_list"; agents: AgentSnapshotPayload[] }
|
||||||
| { type: "status"; payload: { status: string } & Record<string, unknown> }
|
| { type: "status"; payload: { status: string } & Record<string, unknown> }
|
||||||
| { type: "agent_deleted"; agentId: string }
|
| { type: "agent_deleted"; agentId: string }
|
||||||
| {
|
| {
|
||||||
@@ -249,6 +255,10 @@ export class DaemonClientV2 {
|
|||||||
private connectionState: ConnectionState = { status: "idle" };
|
private connectionState: ConnectionState = { status: "idle" };
|
||||||
private messageQueueLimit: number | null;
|
private messageQueueLimit: number | null;
|
||||||
private agentIndex: Map<string, AgentSnapshotPayload> = new Map();
|
private agentIndex: Map<string, AgentSnapshotPayload> = new Map();
|
||||||
|
private agentUpdateSubscriptions = new Map<
|
||||||
|
string,
|
||||||
|
{ labels?: Record<string, string> } | undefined
|
||||||
|
>();
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private pendingSendQueue: PendingSend[] = [];
|
private pendingSendQueue: PendingSend[] = [];
|
||||||
|
|
||||||
@@ -321,6 +331,7 @@ export class DaemonClientV2 {
|
|||||||
this.lastErrorValue = null;
|
this.lastErrorValue = null;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
this.updateConnectionState({ status: "connected" });
|
this.updateConnectionState({ status: "connected" });
|
||||||
|
this.resubscribeAgentUpdates();
|
||||||
this.flushPendingSendQueue();
|
this.flushPendingSendQueue();
|
||||||
this.resolveConnect();
|
this.resolveConnect();
|
||||||
}),
|
}),
|
||||||
@@ -645,41 +656,85 @@ export class DaemonClientV2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Voice Conversation RPC
|
// Agent List RPC
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
requestSessionState(requestId?: string): void {
|
requestAgentList(options?: { filter?: { labels?: Record<string, string> }; requestId?: string }): void {
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
type: "request_session_state",
|
type: "request_agent_list",
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
|
...(options?.filter ? { filter: options.filter } : {}),
|
||||||
});
|
});
|
||||||
this.sendSessionMessage(message);
|
this.sendSessionMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForSessionState(timeout = 5000, requestId?: string): Promise<void> {
|
subscribeAgentUpdates(options?: {
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
subscriptionId?: string;
|
||||||
|
filter?: { labels?: Record<string, string> };
|
||||||
|
}): string {
|
||||||
|
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
||||||
|
this.agentUpdateSubscriptions.set(subscriptionId, options?.filter?.labels);
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
type: "request_session_state",
|
type: "subscribe_agent_updates",
|
||||||
|
subscriptionId,
|
||||||
|
...(options?.filter ? { filter: options.filter } : {}),
|
||||||
|
});
|
||||||
|
this.sendSessionMessage(message);
|
||||||
|
return subscriptionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribeAgentUpdates(subscriptionId: string): void {
|
||||||
|
this.agentUpdateSubscriptions.delete(subscriptionId);
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "unsubscribe_agent_updates",
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
this.sendSessionMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resubscribeAgentUpdates(): void {
|
||||||
|
if (this.agentUpdateSubscriptions.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [subscriptionId, labels] of this.agentUpdateSubscriptions) {
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "subscribe_agent_updates",
|
||||||
|
subscriptionId,
|
||||||
|
...(labels ? { filter: { labels } } : {}),
|
||||||
|
});
|
||||||
|
this.sendSessionMessage(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForAgentList(timeout = 5000, options?: { filter?: { labels?: Record<string, string> }; requestId?: string }): Promise<void> {
|
||||||
|
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "request_agent_list",
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
|
...(options?.filter ? { filter: options.filter } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// First check the existing message queue in case session_state was already received
|
// First check the existing message queue in case agent_list was already received
|
||||||
for (const msg of this.messageQueue) {
|
for (const msg of this.messageQueue) {
|
||||||
if (msg.type === "session_state") {
|
if (msg.type === "agent_list") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in queue, wait for the session_state message
|
// If not in queue, wait for the agent_list message
|
||||||
await this.sendSessionMessageOrThrow(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return this.waitFor(
|
return this.waitFor(
|
||||||
(msg) => msg.type === "session_state" ? undefined : null,
|
(msg) => msg.type === "agent_list" ? undefined : null,
|
||||||
timeout,
|
timeout,
|
||||||
{ skipQueue: false }
|
{ skipQueue: false }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Voice Conversation RPC
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
async loadVoiceConversation(
|
async loadVoiceConversation(
|
||||||
voiceConversationId: string,
|
voiceConversationId: string,
|
||||||
requestId?: string
|
requestId?: string
|
||||||
@@ -805,7 +860,7 @@ export class DaemonClientV2 {
|
|||||||
throw new Error(status.error);
|
throw new Error(status.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.waitForAgentState(
|
return this.waitForAgentUpsert(
|
||||||
status.agentId,
|
status.agentId,
|
||||||
(snapshot) => snapshot.status === "idle",
|
(snapshot) => snapshot.status === "idle",
|
||||||
60000
|
60000
|
||||||
@@ -935,7 +990,7 @@ export class DaemonClientV2 {
|
|||||||
await this.sendSessionMessageOrThrow(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
const status = await statusPromise;
|
const status = await statusPromise;
|
||||||
|
|
||||||
return this.waitForAgentState(
|
return this.waitForAgentUpsert(
|
||||||
status.agentId,
|
status.agentId,
|
||||||
(snapshot) => snapshot.status === "idle",
|
(snapshot) => snapshot.status === "idle",
|
||||||
60000
|
60000
|
||||||
@@ -998,7 +1053,7 @@ export class DaemonClientV2 {
|
|||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
return this.waitForAgentState(agentId, () => true, 10000);
|
return this.waitForAgentUpsert(agentId, () => true, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1824,7 +1879,7 @@ export class DaemonClientV2 {
|
|||||||
// Waiting / Streaming Helpers
|
// Waiting / Streaming Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async waitForAgentState(
|
async waitForAgentUpsert(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
predicate: (snapshot: AgentSnapshotPayload) => boolean,
|
predicate: (snapshot: AgentSnapshotPayload) => boolean,
|
||||||
timeout = 60000
|
timeout = 60000
|
||||||
@@ -1835,9 +1890,13 @@ export class DaemonClientV2 {
|
|||||||
}
|
}
|
||||||
return this.waitFor(
|
return this.waitFor(
|
||||||
(msg) => {
|
(msg) => {
|
||||||
if (msg.type === "agent_state" && msg.payload.id === agentId) {
|
if (
|
||||||
if (predicate(msg.payload)) {
|
msg.type === "agent_update" &&
|
||||||
return msg.payload;
|
msg.payload.kind === "upsert" &&
|
||||||
|
msg.payload.agent.id === agentId
|
||||||
|
) {
|
||||||
|
if (predicate(msg.payload.agent)) {
|
||||||
|
return msg.payload.agent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -1891,12 +1950,16 @@ export class DaemonClientV2 {
|
|||||||
|
|
||||||
for (const msg of this.messageQueue) {
|
for (const msg of this.messageQueue) {
|
||||||
updatePendingPermissions(msg);
|
updatePendingPermissions(msg);
|
||||||
if (msg.type !== "agent_state" || msg.payload.id !== agentId) {
|
if (
|
||||||
|
msg.type !== "agent_update" ||
|
||||||
|
msg.payload.kind !== "upsert" ||
|
||||||
|
msg.payload.agent.id !== agentId
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const status = msg.payload.status;
|
const status = msg.payload.agent.status;
|
||||||
const hasPendingPermissions =
|
const hasPendingPermissions =
|
||||||
(msg.payload.pendingPermissions?.length ?? 0) > 0 ||
|
(msg.payload.agent.pendingPermissions?.length ?? 0) > 0 ||
|
||||||
pendingPermissionIds.size > 0;
|
pendingPermissionIds.size > 0;
|
||||||
if (status === "running" || hasPendingPermissions) {
|
if (status === "running" || hasPendingPermissions) {
|
||||||
sawRunningInQueue = true;
|
sawRunningInQueue = true;
|
||||||
@@ -1904,14 +1967,14 @@ export class DaemonClientV2 {
|
|||||||
}
|
}
|
||||||
// Return immediately if we have pending permissions (even if still running)
|
// Return immediately if we have pending permissions (even if still running)
|
||||||
if (sawRunningInQueue && hasPendingPermissions) {
|
if (sawRunningInQueue && hasPendingPermissions) {
|
||||||
return msg.payload;
|
return msg.payload.agent;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
sawRunningInQueue &&
|
sawRunningInQueue &&
|
||||||
(status === "idle" || status === "error") &&
|
(status === "idle" || status === "error") &&
|
||||||
!hasPendingPermissions
|
!hasPendingPermissions
|
||||||
) {
|
) {
|
||||||
queuedIdle = msg.payload;
|
queuedIdle = msg.payload.agent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (queuedIdle) {
|
if (queuedIdle) {
|
||||||
@@ -1930,10 +1993,14 @@ export class DaemonClientV2 {
|
|||||||
return this.waitFor(
|
return this.waitFor(
|
||||||
(msg) => {
|
(msg) => {
|
||||||
updatePendingPermissions(msg);
|
updatePendingPermissions(msg);
|
||||||
if (msg.type === "agent_state" && msg.payload.id === agentId) {
|
if (
|
||||||
const status = msg.payload.status;
|
msg.type === "agent_update" &&
|
||||||
|
msg.payload.kind === "upsert" &&
|
||||||
|
msg.payload.agent.id === agentId
|
||||||
|
) {
|
||||||
|
const status = msg.payload.agent.status;
|
||||||
const hasPendingPermissions =
|
const hasPendingPermissions =
|
||||||
(msg.payload.pendingPermissions?.length ?? 0) > 0 ||
|
(msg.payload.agent.pendingPermissions?.length ?? 0) > 0 ||
|
||||||
pendingPermissionIds.size > 0;
|
pendingPermissionIds.size > 0;
|
||||||
if (status === "running" || hasPendingPermissions) {
|
if (status === "running" || hasPendingPermissions) {
|
||||||
sawRunning = true;
|
sawRunning = true;
|
||||||
@@ -1941,14 +2008,14 @@ export class DaemonClientV2 {
|
|||||||
// Return if we have pending permissions (even if still running)
|
// Return if we have pending permissions (even if still running)
|
||||||
// OR if agent is idle/error with no pending permissions (after having run)
|
// OR if agent is idle/error with no pending permissions (after having run)
|
||||||
if (sawRunning && hasPendingPermissions) {
|
if (sawRunning && hasPendingPermissions) {
|
||||||
return msg.payload;
|
return msg.payload.agent;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
sawRunning &&
|
sawRunning &&
|
||||||
(status === "idle" || status === "error") &&
|
(status === "idle" || status === "error") &&
|
||||||
!hasPendingPermissions
|
!hasPendingPermissions
|
||||||
) {
|
) {
|
||||||
return msg.payload;
|
return msg.payload.agent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -2298,12 +2365,16 @@ export class DaemonClientV2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleSessionMessage(msg: SessionOutboundMessage): void {
|
private handleSessionMessage(msg: SessionOutboundMessage): void {
|
||||||
if (msg.type === "session_state") {
|
if (msg.type === "agent_list") {
|
||||||
this.agentIndex = new Map(
|
this.agentIndex = new Map(
|
||||||
msg.payload.agents.map((agent) => [agent.id, agent])
|
msg.payload.agents.map((agent) => [agent.id, agent])
|
||||||
);
|
);
|
||||||
} else if (msg.type === "agent_state") {
|
} else if (msg.type === "agent_update") {
|
||||||
this.agentIndex.set(msg.payload.id, msg.payload);
|
if (msg.payload.kind === "upsert") {
|
||||||
|
this.agentIndex.set(msg.payload.agent.id, msg.payload.agent);
|
||||||
|
} else if (msg.payload.kind === "remove") {
|
||||||
|
this.agentIndex.delete(msg.payload.agentId);
|
||||||
|
}
|
||||||
} else if (msg.type === "agent_deleted") {
|
} else if (msg.type === "agent_deleted") {
|
||||||
this.agentIndex.delete(msg.payload.agentId);
|
this.agentIndex.delete(msg.payload.agentId);
|
||||||
}
|
}
|
||||||
@@ -2367,10 +2438,13 @@ export class DaemonClientV2 {
|
|||||||
|
|
||||||
private toEvent(msg: SessionOutboundMessage): DaemonEvent | null {
|
private toEvent(msg: SessionOutboundMessage): DaemonEvent | null {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "agent_state":
|
case "agent_update":
|
||||||
return {
|
return {
|
||||||
type: "agent_state",
|
type: "agent_update",
|
||||||
agentId: msg.payload.id,
|
agentId:
|
||||||
|
msg.payload.kind === "upsert"
|
||||||
|
? msg.payload.agent.id
|
||||||
|
: msg.payload.agentId,
|
||||||
payload: msg.payload,
|
payload: msg.payload,
|
||||||
};
|
};
|
||||||
case "agent_stream":
|
case "agent_stream":
|
||||||
@@ -2380,8 +2454,8 @@ export class DaemonClientV2 {
|
|||||||
event: msg.payload.event,
|
event: msg.payload.event,
|
||||||
timestamp: msg.payload.timestamp,
|
timestamp: msg.payload.timestamp,
|
||||||
};
|
};
|
||||||
case "session_state":
|
case "agent_list":
|
||||||
return { type: "session_state", agents: msg.payload.agents };
|
return { type: "agent_list", agents: msg.payload.agents };
|
||||||
case "status":
|
case "status":
|
||||||
return { type: "status", payload: msg.payload };
|
return { type: "status", payload: msg.payload };
|
||||||
case "agent_deleted":
|
case "agent_deleted":
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Agent Self-ID Bridge
|
||||||
|
*
|
||||||
|
* Bridges stdio MCP transport to HTTP-over-Unix-socket transport.
|
||||||
|
* This allows coding agents (which only support stdio or HTTP MCP) to
|
||||||
|
* call set_title and set_branch on the Paseo daemon.
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* Coding Agent (Claude Code / Codex)
|
||||||
|
* |
|
||||||
|
* | stdio (newline-delimited JSON-RPC)
|
||||||
|
* v
|
||||||
|
* paseo self-id-bridge (this module)
|
||||||
|
* |
|
||||||
|
* | HTTP over Unix socket (${PASEO_HOME}/self-id-mcp.sock)
|
||||||
|
* v
|
||||||
|
* Paseo Daemon (Agent Self-ID MCP Server)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
export interface SelfIdBridgeOptions {
|
||||||
|
socketPath: string;
|
||||||
|
agentId?: string;
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonRpcRequest {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
id?: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
result?: unknown;
|
||||||
|
error?: { code: number; message: string; data?: unknown };
|
||||||
|
id: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(debug: boolean, ...args: unknown[]): void {
|
||||||
|
if (debug) {
|
||||||
|
console.error("[self-id-bridge]", ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHttpRequest(
|
||||||
|
socketPath: string,
|
||||||
|
urlPath: string,
|
||||||
|
body: string,
|
||||||
|
headers: Record<string, string>
|
||||||
|
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
socketPath,
|
||||||
|
path: urlPath,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(body),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
resolve({
|
||||||
|
status: res.statusCode ?? 500,
|
||||||
|
headers: res.headers,
|
||||||
|
body: Buffer.concat(chunks).toString("utf-8"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
res.on("error", reject);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeResponse(response: JsonRpcResponse): void {
|
||||||
|
const line = JSON.stringify(response);
|
||||||
|
process.stdout.write(line + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeError(id: string | number | null, code: number, message: string): void {
|
||||||
|
writeResponse({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: { code, message },
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSelfIdBridge(options: SelfIdBridgeOptions): Promise<void> {
|
||||||
|
const { socketPath, agentId, debug = false } = options;
|
||||||
|
|
||||||
|
log(debug, `Starting Self-ID bridge to ${socketPath}`);
|
||||||
|
if (agentId) {
|
||||||
|
log(debug, `Agent ID: ${agentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mcpSessionId: string | null = null;
|
||||||
|
let protocolVersion: string | null = null;
|
||||||
|
|
||||||
|
const rl = createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let request: JsonRpcRequest;
|
||||||
|
try {
|
||||||
|
request = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
log(debug, "Failed to parse JSON:", line);
|
||||||
|
writeError(null, -32700, "Parse error");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(debug, "Request:", request.method, request.id);
|
||||||
|
|
||||||
|
// Build headers
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
};
|
||||||
|
if (mcpSessionId) {
|
||||||
|
headers["mcp-session-id"] = mcpSessionId;
|
||||||
|
}
|
||||||
|
if (protocolVersion && request.method !== "initialize") {
|
||||||
|
headers["mcp-protocol-version"] = protocolVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build URL with callerAgentId if provided
|
||||||
|
let path = "/";
|
||||||
|
if (agentId) {
|
||||||
|
path = `/?callerAgentId=${encodeURIComponent(agentId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await makeHttpRequest(
|
||||||
|
socketPath,
|
||||||
|
path,
|
||||||
|
JSON.stringify(request),
|
||||||
|
headers
|
||||||
|
);
|
||||||
|
|
||||||
|
log(debug, "Response status:", response.status);
|
||||||
|
|
||||||
|
// Check for session ID in response headers
|
||||||
|
const newSessionId = response.headers["mcp-session-id"];
|
||||||
|
if (typeof newSessionId === "string" && newSessionId !== mcpSessionId) {
|
||||||
|
mcpSessionId = newSessionId;
|
||||||
|
log(debug, "Session ID:", mcpSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle content type
|
||||||
|
const contentType = response.headers["content-type"] ?? "";
|
||||||
|
|
||||||
|
if (contentType.includes("text/event-stream")) {
|
||||||
|
// SSE response - parse events and write each as a line
|
||||||
|
const events = parseSSE(response.body);
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.data) {
|
||||||
|
process.stdout.write(event.data + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// JSON response - write as-is
|
||||||
|
const jsonResponse = JSON.parse(response.body) as JsonRpcResponse;
|
||||||
|
|
||||||
|
// Extract protocol version from initialize response
|
||||||
|
if (request.method === "initialize" && jsonResponse.result) {
|
||||||
|
const result = jsonResponse.result as { protocolVersion?: string };
|
||||||
|
if (result.protocolVersion) {
|
||||||
|
protocolVersion = result.protocolVersion;
|
||||||
|
log(debug, "Protocol version:", protocolVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeResponse(jsonResponse);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
log(debug, "HTTP error:", message);
|
||||||
|
|
||||||
|
// Check if it's a connection error
|
||||||
|
if (message.includes("ENOENT") || message.includes("ECONNREFUSED")) {
|
||||||
|
writeError(
|
||||||
|
request.id ?? null,
|
||||||
|
-32603,
|
||||||
|
`Paseo daemon unreachable at ${socketPath}. Is the daemon running?`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
writeError(request.id ?? null, -32603, `Internal error: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(debug, "stdin closed, exiting");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSEEvent {
|
||||||
|
event?: string;
|
||||||
|
data?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSSE(body: string): SSEEvent[] {
|
||||||
|
const events: SSEEvent[] = [];
|
||||||
|
let currentEvent: SSEEvent = {};
|
||||||
|
let dataLines: string[] = [];
|
||||||
|
|
||||||
|
for (const line of body.split("\n")) {
|
||||||
|
if (line === "") {
|
||||||
|
// End of event
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
currentEvent.data = dataLines.join("\n");
|
||||||
|
}
|
||||||
|
if (Object.keys(currentEvent).length > 0) {
|
||||||
|
events.push(currentEvent);
|
||||||
|
}
|
||||||
|
currentEvent = {};
|
||||||
|
dataLines = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith(":")) {
|
||||||
|
// Comment, ignore
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonIndex = line.indexOf(":");
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
// Field with no value
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = line.slice(0, colonIndex);
|
||||||
|
let value = line.slice(colonIndex + 1);
|
||||||
|
if (value.startsWith(" ")) {
|
||||||
|
value = value.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case "event":
|
||||||
|
currentEvent.event = value;
|
||||||
|
break;
|
||||||
|
case "data":
|
||||||
|
dataLines.push(value);
|
||||||
|
break;
|
||||||
|
case "id":
|
||||||
|
currentEvent.id = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle final event if no trailing newline
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
currentEvent.data = dataLines.join("\n");
|
||||||
|
}
|
||||||
|
if (Object.keys(currentEvent).length > 0) {
|
||||||
|
events.push(currentEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
/**
|
||||||
|
* Agent Management MCP Server
|
||||||
|
*
|
||||||
|
* Purpose: Managing agents from the UI/voice assistant LLM
|
||||||
|
* Transport: In-memory (runs in-process with the voice assistant LLM)
|
||||||
|
* Server name: "paseo-agent-management"
|
||||||
|
*
|
||||||
|
* Tools:
|
||||||
|
* - create_agent
|
||||||
|
* - wait_for_agent
|
||||||
|
* - send_agent_prompt
|
||||||
|
* - get_agent_status
|
||||||
|
* - list_agents
|
||||||
|
* - cancel_agent
|
||||||
|
* - kill_agent
|
||||||
|
* - get_agent_activity
|
||||||
|
* - set_agent_mode
|
||||||
|
* - list_pending_permissions
|
||||||
|
* - respond_to_permission
|
||||||
|
*
|
||||||
|
* No callerAgentId needed - voice assistant is not an agent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { ensureValidJson } from "../json-utils.js";
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AgentPromptInput,
|
||||||
|
AgentProvider,
|
||||||
|
AgentPermissionRequest,
|
||||||
|
} from "./agent-sdk-types.js";
|
||||||
|
import type {
|
||||||
|
AgentManager,
|
||||||
|
ManagedAgent,
|
||||||
|
WaitForAgentResult,
|
||||||
|
} from "./agent-manager.js";
|
||||||
|
import {
|
||||||
|
AgentPermissionRequestPayloadSchema,
|
||||||
|
AgentPermissionResponseSchema,
|
||||||
|
AgentSnapshotPayloadSchema,
|
||||||
|
serializeAgentSnapshot,
|
||||||
|
} from "../messages.js";
|
||||||
|
import { toAgentPayload } from "./agent-projections.js";
|
||||||
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
|
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||||
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
|
import { createWorktree } from "../../utils/worktree.js";
|
||||||
|
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||||
|
import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js";
|
||||||
|
|
||||||
|
export interface AgentManagementMcpOptions {
|
||||||
|
agentManager: AgentManager;
|
||||||
|
agentStorage: AgentStorage;
|
||||||
|
paseoHome?: string;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AgentProviderEnum = z.enum(
|
||||||
|
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
|
||||||
|
AgentProvider,
|
||||||
|
...AgentProvider[],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const AgentStatusEnum = z.enum([
|
||||||
|
"initializing",
|
||||||
|
"idle",
|
||||||
|
"running",
|
||||||
|
"error",
|
||||||
|
"closed",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
|
||||||
|
const AGENT_WAIT_TIMEOUT_MS = 50000;
|
||||||
|
|
||||||
|
function expandPath(path: string): string {
|
||||||
|
if (path.startsWith("~/") || path === "~") {
|
||||||
|
return resolve(homedir(), path.slice(2));
|
||||||
|
}
|
||||||
|
return resolve(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAgentWithTimeout(
|
||||||
|
agentManager: AgentManager,
|
||||||
|
agentId: string,
|
||||||
|
options?: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
waitForActive?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<WaitForAgentResult> {
|
||||||
|
const timeoutController = new AbortController();
|
||||||
|
const combinedController = new AbortController();
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
timeoutController.abort(new Error("wait timeout"));
|
||||||
|
}, AGENT_WAIT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const forwardAbort = (reason: unknown) => {
|
||||||
|
if (!combinedController.signal.aborted) {
|
||||||
|
combinedController.abort(reason);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.signal) {
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
forwardAbort(options.signal.reason);
|
||||||
|
} else {
|
||||||
|
options.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => forwardAbort(options.signal!.reason),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutController.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => forwardAbort(timeoutController.signal.reason),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await agentManager.waitForAgentEvent(agentId, {
|
||||||
|
signal: combinedController.signal,
|
||||||
|
waitForActive: options?.waitForActive,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "wait timeout") {
|
||||||
|
const snapshot = agentManager.getAgent(agentId);
|
||||||
|
const timeline = agentManager.getTimeline(agentId);
|
||||||
|
const recentActivity = curateAgentActivity(timeline.slice(-5));
|
||||||
|
const message = `Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
|
||||||
|
return {
|
||||||
|
status: snapshot?.lifecycle ?? "idle",
|
||||||
|
permission: null,
|
||||||
|
lastMessage: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAgentRun(
|
||||||
|
agentManager: AgentManager,
|
||||||
|
agentId: string,
|
||||||
|
prompt: AgentPromptInput,
|
||||||
|
logger: Logger
|
||||||
|
): void {
|
||||||
|
const iterator = agentManager.streamAgent(agentId, prompt);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
for await (const _ of iterator) {
|
||||||
|
// Events are broadcast via AgentManager subscribers.
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error, agentId }, "Agent stream failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePermissionRequest(
|
||||||
|
permission: AgentPermissionRequest | null | undefined
|
||||||
|
): AgentPermissionRequest | null {
|
||||||
|
if (!permission) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sanitized: AgentPermissionRequest = { ...permission };
|
||||||
|
if (sanitized.title === undefined) {
|
||||||
|
delete sanitized.title;
|
||||||
|
}
|
||||||
|
if (sanitized.description === undefined) {
|
||||||
|
delete sanitized.description;
|
||||||
|
}
|
||||||
|
if (sanitized.input === undefined) {
|
||||||
|
delete sanitized.input;
|
||||||
|
}
|
||||||
|
if (sanitized.suggestions === undefined) {
|
||||||
|
delete sanitized.suggestions;
|
||||||
|
}
|
||||||
|
if (sanitized.metadata === undefined) {
|
||||||
|
delete sanitized.metadata;
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAgentTitle(
|
||||||
|
agentStorage: AgentStorage,
|
||||||
|
agentId: string,
|
||||||
|
logger: Logger
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const record = await agentStorage.get(agentId);
|
||||||
|
return record?.title ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error, agentId }, "Failed to load agent title");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serializeSnapshotWithMetadata(
|
||||||
|
agentStorage: AgentStorage,
|
||||||
|
snapshot: ManagedAgent,
|
||||||
|
logger: Logger
|
||||||
|
) {
|
||||||
|
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
|
||||||
|
return serializeAgentSnapshot(snapshot, { title });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAgentManagementMcpServer(
|
||||||
|
options: AgentManagementMcpOptions
|
||||||
|
): Promise<McpServer> {
|
||||||
|
const { agentManager, agentStorage, logger } = options;
|
||||||
|
const childLogger = logger.child({
|
||||||
|
module: "agent",
|
||||||
|
component: "agent-management-mcp",
|
||||||
|
});
|
||||||
|
const waitTracker = new WaitForAgentTracker(logger);
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "paseo-agent-management",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputSchema = {
|
||||||
|
cwd: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Required working directory for the agent (absolute, relative, or ~)."
|
||||||
|
),
|
||||||
|
title: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Title is required")
|
||||||
|
.max(60, "Title must be 60 characters or fewer")
|
||||||
|
.describe(
|
||||||
|
"Short descriptive title (<= 60 chars) summarizing the agent's focus."
|
||||||
|
),
|
||||||
|
agentType: AgentProviderEnum.optional().describe(
|
||||||
|
"Optional agent implementation to spawn. Defaults to 'claude'."
|
||||||
|
),
|
||||||
|
initialPrompt: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional task to start immediately after creation (non-blocking)."
|
||||||
|
),
|
||||||
|
initialMode: z
|
||||||
|
.string()
|
||||||
|
.describe("Required session mode to configure before the first run."),
|
||||||
|
worktreeName: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional git worktree branch name (lowercase alphanumerics + hyphen)."
|
||||||
|
),
|
||||||
|
baseBranch: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Required when worktreeName is set: the base branch to diff/merge against."
|
||||||
|
),
|
||||||
|
background: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
|
.describe(
|
||||||
|
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"create_agent",
|
||||||
|
{
|
||||||
|
title: "Create Agent",
|
||||||
|
description:
|
||||||
|
"Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.",
|
||||||
|
inputSchema,
|
||||||
|
outputSchema: {
|
||||||
|
agentId: z.string(),
|
||||||
|
type: AgentProviderEnum,
|
||||||
|
status: AgentStatusEnum,
|
||||||
|
cwd: z.string(),
|
||||||
|
currentModeId: z.string().nullable(),
|
||||||
|
availableModes: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
lastMessage: z.string().nullable().optional(),
|
||||||
|
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const {
|
||||||
|
cwd,
|
||||||
|
agentType,
|
||||||
|
initialPrompt,
|
||||||
|
initialMode,
|
||||||
|
worktreeName,
|
||||||
|
baseBranch,
|
||||||
|
background = false,
|
||||||
|
title,
|
||||||
|
} = args as {
|
||||||
|
cwd: string;
|
||||||
|
agentType?: AgentProvider;
|
||||||
|
initialPrompt?: string;
|
||||||
|
initialMode: string;
|
||||||
|
worktreeName?: string;
|
||||||
|
baseBranch?: string;
|
||||||
|
background?: boolean;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolvedCwd = expandPath(cwd);
|
||||||
|
|
||||||
|
if (worktreeName) {
|
||||||
|
if (!baseBranch) {
|
||||||
|
throw new Error("baseBranch is required when creating a worktree");
|
||||||
|
}
|
||||||
|
const worktree = await createWorktree({
|
||||||
|
branchName: worktreeName,
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
baseBranch,
|
||||||
|
worktreeSlug: worktreeName,
|
||||||
|
paseoHome: options.paseoHome,
|
||||||
|
});
|
||||||
|
resolvedCwd = worktree.worktreePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider: AgentProvider = agentType ?? "claude";
|
||||||
|
const normalizedTitle = title?.trim() ?? null;
|
||||||
|
const snapshot = await agentManager.createAgent({
|
||||||
|
provider,
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
modeId: initialMode,
|
||||||
|
title: normalizedTitle ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initialPrompt) {
|
||||||
|
const initialPromptWithInstructions = injectLeadingPaseoInstructionTag(
|
||||||
|
initialPrompt,
|
||||||
|
snapshot.config.paseoPromptInstructions
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
agentManager.recordUserMessage(
|
||||||
|
snapshot.id,
|
||||||
|
initialPromptWithInstructions
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
childLogger.error(
|
||||||
|
{ err: error, agentId: snapshot.id },
|
||||||
|
"Failed to record initial prompt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startAgentRun(
|
||||||
|
agentManager,
|
||||||
|
snapshot.id,
|
||||||
|
initialPromptWithInstructions,
|
||||||
|
childLogger
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!background) {
|
||||||
|
const result = await waitForAgentWithTimeout(
|
||||||
|
agentManager,
|
||||||
|
snapshot.id,
|
||||||
|
{ waitForActive: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = {
|
||||||
|
agentId: snapshot.id,
|
||||||
|
type: provider,
|
||||||
|
status: result.status,
|
||||||
|
cwd: snapshot.cwd,
|
||||||
|
currentModeId: snapshot.currentModeId,
|
||||||
|
availableModes: snapshot.availableModes,
|
||||||
|
lastMessage: result.lastMessage,
|
||||||
|
permission: sanitizePermissionRequest(result.permission),
|
||||||
|
};
|
||||||
|
const validJson = ensureValidJson(responseData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: validJson,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
childLogger.error(
|
||||||
|
{ err: error, agentId: snapshot.id },
|
||||||
|
"Failed to run initial prompt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({
|
||||||
|
agentId: snapshot.id,
|
||||||
|
type: provider,
|
||||||
|
status: snapshot.lifecycle,
|
||||||
|
cwd: snapshot.cwd,
|
||||||
|
currentModeId: snapshot.currentModeId,
|
||||||
|
availableModes: snapshot.availableModes,
|
||||||
|
lastMessage: null,
|
||||||
|
permission: null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"wait_for_agent",
|
||||||
|
{
|
||||||
|
title: "Wait For Agent",
|
||||||
|
description:
|
||||||
|
"Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.",
|
||||||
|
inputSchema: {
|
||||||
|
agentId: z
|
||||||
|
.string()
|
||||||
|
.describe("Agent identifier returned by the create_agent tool"),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
agentId: z.string(),
|
||||||
|
status: AgentStatusEnum,
|
||||||
|
permission: AgentPermissionRequestPayloadSchema.nullable(),
|
||||||
|
lastMessage: z.string().nullable(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ agentId }, { signal }) => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const cleanupFns: Array<() => void> = [];
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
while (cleanupFns.length) {
|
||||||
|
const fn = cleanupFns.pop();
|
||||||
|
try {
|
||||||
|
fn?.();
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const forwardExternalAbort = () => {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
const reason = signal?.reason ?? new Error("wait_for_agent aborted");
|
||||||
|
abortController.abort(reason);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
forwardExternalAbort();
|
||||||
|
} else {
|
||||||
|
signal.addEventListener("abort", forwardExternalAbort, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
cleanupFns.push(() =>
|
||||||
|
signal.removeEventListener("abort", forwardExternalAbort)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregister = waitTracker.register(agentId, (reason) => {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
abortController.abort(
|
||||||
|
new Error(reason ?? "wait_for_agent cancelled")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cleanupFns.push(unregister);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: WaitForAgentResult = await waitForAgentWithTimeout(
|
||||||
|
agentManager,
|
||||||
|
agentId,
|
||||||
|
{ signal: abortController.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
const validJson = ensureValidJson({
|
||||||
|
agentId,
|
||||||
|
status: result.status,
|
||||||
|
permission: sanitizePermissionRequest(result.permission),
|
||||||
|
lastMessage: result.lastMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: validJson,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"send_agent_prompt",
|
||||||
|
{
|
||||||
|
title: "Send Agent Prompt",
|
||||||
|
description:
|
||||||
|
"Send a task to a running agent. Returns immediately after the agent begins processing.",
|
||||||
|
inputSchema: {
|
||||||
|
agentId: z.string(),
|
||||||
|
prompt: z.string(),
|
||||||
|
sessionMode: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Optional mode to set before running the prompt."),
|
||||||
|
background: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
|
.describe(
|
||||||
|
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
success: z.boolean(),
|
||||||
|
status: AgentStatusEnum,
|
||||||
|
lastMessage: z.string().nullable().optional(),
|
||||||
|
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ agentId, prompt, sessionMode, background = false }) => {
|
||||||
|
const snapshot = agentManager.getAgent(agentId);
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new Error(`Agent ${agentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.lifecycle === "running" || snapshot.pendingRun) {
|
||||||
|
childLogger.debug(
|
||||||
|
{ agentId },
|
||||||
|
"Interrupting active run before sending new prompt"
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const cancelled = await agentManager.cancelAgentRun(agentId);
|
||||||
|
if (!cancelled) {
|
||||||
|
childLogger.warn(
|
||||||
|
{ agentId },
|
||||||
|
"Agent reported running but no active run was cancelled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
|
||||||
|
|
||||||
|
const maxWaitMs = 5000;
|
||||||
|
const pollIntervalMs = 50;
|
||||||
|
const startTime = Date.now();
|
||||||
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
|
const current = agentManager.getAgent(agentId);
|
||||||
|
if (!current) {
|
||||||
|
throw new Error(
|
||||||
|
`Agent ${agentId} not found during cancellation wait`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (current.lifecycle !== "running" && !current.pendingRun) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, pollIntervalMs)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
childLogger.error(
|
||||||
|
{ err: error, agentId },
|
||||||
|
"Failed to interrupt agent"
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionMode) {
|
||||||
|
await agentManager.setAgentMode(agentId, sessionMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
agentManager.recordUserMessage(agentId, prompt);
|
||||||
|
} catch (error) {
|
||||||
|
childLogger.error(
|
||||||
|
{ err: error, agentId },
|
||||||
|
"Failed to record user message"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
startAgentRun(agentManager, agentId, prompt, childLogger);
|
||||||
|
|
||||||
|
if (!background) {
|
||||||
|
const result = await waitForAgentWithTimeout(agentManager, agentId, {
|
||||||
|
waitForActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseData = {
|
||||||
|
success: true,
|
||||||
|
status: result.status,
|
||||||
|
lastMessage: result.lastMessage,
|
||||||
|
permission: sanitizePermissionRequest(result.permission),
|
||||||
|
};
|
||||||
|
const validJson = ensureValidJson(responseData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: validJson,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSnapshot = agentManager.getAgent(agentId);
|
||||||
|
|
||||||
|
const responseData = {
|
||||||
|
success: true,
|
||||||
|
status: currentSnapshot?.lifecycle ?? "idle",
|
||||||
|
lastMessage: null,
|
||||||
|
permission: null,
|
||||||
|
};
|
||||||
|
const validJson = ensureValidJson(responseData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: validJson,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_agent_status",
|
||||||
|
{
|
||||||
|
title: "Get Agent Status",
|
||||||
|
description:
|
||||||
|
"Return the latest snapshot for an agent, including lifecycle state, capabilities, and pending permissions.",
|
||||||
|
inputSchema: {
|
||||||
|
agentId: z.string(),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
status: AgentStatusEnum,
|
||||||
|
snapshot: AgentSnapshotPayloadSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ agentId }) => {
|
||||||
|
const snapshot = agentManager.getAgent(agentId);
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new Error(`Agent ${agentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const structuredSnapshot = await serializeSnapshotWithMetadata(
|
||||||
|
agentStorage,
|
||||||
|
snapshot,
|
||||||
|
childLogger
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({
|
||||||
|
status: snapshot.lifecycle,
|
||||||
|
snapshot: structuredSnapshot,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_agents",
|
||||||
|
{
|
||||||
|
title: "List Agents",
|
||||||
|
description: "List all live agents managed by the server.",
|
||||||
|
inputSchema: {},
|
||||||
|
outputSchema: {
|
||||||
|
agents: z.array(AgentSnapshotPayloadSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const snapshots = agentManager.listAgents();
|
||||||
|
const agents = await Promise.all(
|
||||||
|
snapshots.map((snapshot) =>
|
||||||
|
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({ agents }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"cancel_agent",
|
||||||
|
{
|
||||||
|
title: "Cancel Agent Run",
|
||||||
|
description:
|
||||||
|
"Abort the agent's current run but keep the agent alive for future tasks.",
|
||||||
|
inputSchema: {
|
||||||
|
agentId: z.string(),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
success: z.boolean(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ agentId }) => {
|
||||||
|
const success = await agentManager.cancelAgentRun(agentId);
|
||||||
|
if (success) {
|
||||||
|
waitTracker.cancel(agentId, "Agent run cancelled");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({ success }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"kill_agent",
|
||||||
|
{
|
||||||
|
title: "Kill Agent",
|
||||||
|
description: "Terminate an agent session permanently.",
|
||||||
|
inputSchema: {
|
||||||
|
agentId: z.string(),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
success: z.boolean(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ agentId }) => {
|
||||||
|
await agentManager.closeAgent(agentId);
|
||||||
|
waitTracker.cancel(agentId, "Agent terminated");
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({ success: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_agent_activity",
|
||||||
|
{
|
||||||
|
title: "Get Agent Activity",
|
||||||
|
description:
|
||||||
|
"Return recent agent timeline entries as a curated summary.",
|
||||||
|
inputSchema: {
|
||||||
|
agentId: z.string(),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional limit for number of activities to include (most recent first)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
agentId: z.string(),
|
||||||
|
updateCount: z.number(),
|
||||||
|
currentModeId: z.string().nullable(),
|
||||||
|
content: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ agentId, limit }) => {
|
||||||
|
const timeline = agentManager.getTimeline(agentId);
|
||||||
|
const snapshot = agentManager.getAgent(agentId);
|
||||||
|
|
||||||
|
const activitiesToCurate = limit ? timeline.slice(-limit) : timeline;
|
||||||
|
|
||||||
|
const curatedContent = curateAgentActivity(activitiesToCurate);
|
||||||
|
const totalCount = timeline.length;
|
||||||
|
const shownCount = activitiesToCurate.length;
|
||||||
|
|
||||||
|
let countHeader: string;
|
||||||
|
if (limit && shownCount < totalCount) {
|
||||||
|
countHeader = `Showing ${shownCount} of ${totalCount} ${totalCount === 1 ? "activity" : "activities"} (limited to ${limit})`;
|
||||||
|
} else {
|
||||||
|
countHeader = `Showing all ${totalCount} ${totalCount === 1 ? "activity" : "activities"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentWithCount = `${countHeader}\n\n${curatedContent}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({
|
||||||
|
agentId,
|
||||||
|
updateCount: timeline.length,
|
||||||
|
currentModeId: snapshot?.currentModeId ?? null,
|
||||||
|
content: contentWithCount,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"set_agent_mode",
|
||||||
|
{
|
||||||
|
title: "Set Agent Session Mode",
|
||||||
|
description:
|
||||||
|
"Switch the agent's session mode (plan, bypassPermissions, read-only, auto, etc.).",
|
||||||
|
inputSchema: {
|
||||||
|
agentId: z.string(),
|
||||||
|
modeId: z.string(),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
success: z.boolean(),
|
||||||
|
newMode: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ agentId, modeId }) => {
|
||||||
|
await agentManager.setAgentMode(agentId, modeId);
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({ success: true, newMode: modeId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_pending_permissions",
|
||||||
|
{
|
||||||
|
title: "List Pending Permissions",
|
||||||
|
description:
|
||||||
|
"Return all pending permission requests across all agents with the normalized payloads.",
|
||||||
|
inputSchema: {},
|
||||||
|
outputSchema: {
|
||||||
|
permissions: z.array(
|
||||||
|
z.object({
|
||||||
|
agentId: z.string(),
|
||||||
|
status: AgentStatusEnum,
|
||||||
|
request: AgentPermissionRequestPayloadSchema,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const permissions = agentManager.listAgents().flatMap((agent) => {
|
||||||
|
const payload = toAgentPayload(agent);
|
||||||
|
return payload.pendingPermissions.map((request) => ({
|
||||||
|
agentId: agent.id,
|
||||||
|
status: payload.status,
|
||||||
|
request,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({ permissions }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"respond_to_permission",
|
||||||
|
{
|
||||||
|
title: "Respond To Permission",
|
||||||
|
description:
|
||||||
|
"Approve or deny a pending permission request with an AgentManager-compatible response payload.",
|
||||||
|
inputSchema: {
|
||||||
|
agentId: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
response: AgentPermissionResponseSchema,
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
success: z.boolean(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ agentId, requestId, response }) => {
|
||||||
|
await agentManager.respondToPermission(agentId, requestId, response);
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({ success: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ export function toStoredAgentRecord(
|
|||||||
? agent.lastUserMessageAt.toISOString()
|
? agent.lastUserMessageAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
title: options?.title ?? null,
|
title: options?.title ?? null,
|
||||||
labels: agent.labels && Object.keys(agent.labels).length > 0 ? agent.labels : undefined,
|
labels: agent.labels,
|
||||||
lastStatus: agent.lifecycle,
|
lastStatus: agent.lifecycle,
|
||||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||||
config: config ?? null,
|
config: config ?? null,
|
||||||
@@ -85,7 +85,7 @@ export function toAgentPayload(
|
|||||||
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||||
persistence: sanitizePersistenceHandle(agent.persistence),
|
persistence: sanitizePersistenceHandle(agent.persistence),
|
||||||
title: options?.title ?? null,
|
title: options?.title ?? null,
|
||||||
labels: agent.labels && Object.keys(agent.labels).length > 0 ? agent.labels : undefined,
|
labels: agent.labels,
|
||||||
};
|
};
|
||||||
|
|
||||||
const usage = sanitizeUsage(agent.lastUsage);
|
const usage = sanitizeUsage(agent.lastUsage);
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Agent Self-ID MCP Server
|
||||||
|
*
|
||||||
|
* Purpose: Agents identifying themselves (title, branch)
|
||||||
|
* Transport: Stdio bridge → Unix socket (${PASEO_HOME}/self-id-mcp.sock)
|
||||||
|
* Server name: "paseo-agent-self-id"
|
||||||
|
*
|
||||||
|
* Tools:
|
||||||
|
* - set_title - Set agent's display title
|
||||||
|
* - set_branch - Rename git branch (Paseo worktrees only)
|
||||||
|
*
|
||||||
|
* Requires callerAgentId - must know which agent is calling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ensureValidJson } from "../json-utils.js";
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
|
import type { AgentManager } from "./agent-manager.js";
|
||||||
|
import {
|
||||||
|
isPaseoOwnedWorktreeCwd,
|
||||||
|
validateBranchSlug,
|
||||||
|
} from "../../utils/worktree.js";
|
||||||
|
import {
|
||||||
|
NotGitRepoError,
|
||||||
|
renameCurrentBranch,
|
||||||
|
} from "../../utils/checkout-git.js";
|
||||||
|
|
||||||
|
export interface AgentSelfIdMcpOptions {
|
||||||
|
agentManager: AgentManager;
|
||||||
|
paseoHome?: string;
|
||||||
|
/**
|
||||||
|
* ID of the agent that is connecting to this MCP server.
|
||||||
|
* Required - this server only works for managed agents.
|
||||||
|
*/
|
||||||
|
callerAgentId: string;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolErrorCode = "NOT_ALLOWED" | "NOT_GIT_REPO" | "INVALID_BRANCH";
|
||||||
|
|
||||||
|
class AgentSelfIdToolError extends Error {
|
||||||
|
readonly code: ToolErrorCode;
|
||||||
|
|
||||||
|
constructor(code: ToolErrorCode, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AgentSelfIdToolError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAgentSelfIdMcpServer(
|
||||||
|
options: AgentSelfIdMcpOptions
|
||||||
|
): Promise<McpServer> {
|
||||||
|
const { agentManager, callerAgentId, logger } = options;
|
||||||
|
const childLogger = logger.child({
|
||||||
|
module: "agent",
|
||||||
|
component: "agent-self-id-mcp",
|
||||||
|
callerAgentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "paseo-agent-self-id",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"set_title",
|
||||||
|
{
|
||||||
|
title: "Set Agent Title",
|
||||||
|
description: "Update the agent's title in the registry.",
|
||||||
|
inputSchema: {
|
||||||
|
title: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(60)
|
||||||
|
.describe("Short descriptive title (<= 60 chars)."),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
success: z.boolean(),
|
||||||
|
title: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ title }) => {
|
||||||
|
const agent = agentManager.getAgent(callerAgentId);
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error(`Agent ${callerAgentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTitle = title.trim();
|
||||||
|
if (!normalizedTitle) {
|
||||||
|
throw new AgentSelfIdToolError("NOT_ALLOWED", "Title cannot be empty");
|
||||||
|
}
|
||||||
|
if (normalizedTitle.length > 60) {
|
||||||
|
throw new AgentSelfIdToolError(
|
||||||
|
"NOT_ALLOWED",
|
||||||
|
"Title must be 60 characters or fewer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
childLogger.debug({ title: normalizedTitle }, "Setting agent title");
|
||||||
|
await agentManager.setTitle(agent.id, normalizedTitle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({
|
||||||
|
success: true,
|
||||||
|
title: normalizedTitle,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"set_branch",
|
||||||
|
{
|
||||||
|
title: "Set Agent Branch",
|
||||||
|
description:
|
||||||
|
"Rename the current git branch. Allowed only inside Paseo-owned worktrees.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
"Git branch name (lowercase letters, numbers, hyphens, slashes)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
success: z.boolean(),
|
||||||
|
branch: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
const agent = agentManager.getAgent(callerAgentId);
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error(`Agent ${callerAgentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateBranchSlug(name);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new AgentSelfIdToolError(
|
||||||
|
"INVALID_BRANCH",
|
||||||
|
validation.error ?? "Invalid branch name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ownership;
|
||||||
|
try {
|
||||||
|
ownership = await isPaseoOwnedWorktreeCwd(agent.cwd, {
|
||||||
|
paseoHome: options.paseoHome,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const notGitError =
|
||||||
|
error instanceof NotGitRepoError
|
||||||
|
? error
|
||||||
|
: new NotGitRepoError(agent.cwd);
|
||||||
|
throw new AgentSelfIdToolError("NOT_GIT_REPO", notGitError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ownership.allowed) {
|
||||||
|
throw new AgentSelfIdToolError(
|
||||||
|
"NOT_ALLOWED",
|
||||||
|
"Branch renames are only allowed inside Paseo-owned worktrees"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
childLogger.debug({ branch: name }, "Renaming branch");
|
||||||
|
const result = await renameCurrentBranch(agent.cwd, name);
|
||||||
|
if (result.currentBranch !== name) {
|
||||||
|
throw new Error(
|
||||||
|
`Branch rename failed (expected ${name}, got ${result.currentBranch ?? "unknown"})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({
|
||||||
|
success: true,
|
||||||
|
branch: name,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
|||||||
lastActivityAt: z.string().optional(),
|
lastActivityAt: z.string().optional(),
|
||||||
lastUserMessageAt: z.string().nullable().optional(),
|
lastUserMessageAt: z.string().nullable().optional(),
|
||||||
title: z.string().nullable().optional(),
|
title: z.string().nullable().optional(),
|
||||||
labels: z.record(z.string()).optional(),
|
labels: z.record(z.string()).default({}),
|
||||||
lastStatus: AgentStatusSchema.default("closed"),
|
lastStatus: AgentStatusSchema.default("closed"),
|
||||||
lastModeId: z.string().nullable().optional(),
|
lastModeId: z.string().nullable().optional(),
|
||||||
config: SERIALIZABLE_CONFIG_SCHEMA,
|
config: SERIALIZABLE_CONFIG_SCHEMA,
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ describe("daemon client v2 E2E", () => {
|
|||||||
test("handles session actions", async () => {
|
test("handles session actions", async () => {
|
||||||
expect(ctx.client.isConnected).toBe(true);
|
expect(ctx.client.isConnected).toBe(true);
|
||||||
|
|
||||||
const sessionStatePromise = waitForSignal(15000, (resolve) => {
|
const agentListPromise = waitForSignal(15000, (resolve) => {
|
||||||
const unsubscribe = ctx.client.on("session_state", (message) => {
|
const unsubscribe = ctx.client.on("agent_list", (message) => {
|
||||||
if (message.type !== "session_state") {
|
if (message.type !== "agent_list") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(message);
|
resolve(message);
|
||||||
@@ -95,8 +95,8 @@ describe("daemon client v2 E2E", () => {
|
|||||||
expect(loadResult.voiceConversationId).toBe(voiceConversationId);
|
expect(loadResult.voiceConversationId).toBe(voiceConversationId);
|
||||||
expect(typeof loadResult.messageCount).toBe("number");
|
expect(typeof loadResult.messageCount).toBe("number");
|
||||||
|
|
||||||
const sessionState = await sessionStatePromise;
|
const agentList = await agentListPromise;
|
||||||
expect(Array.isArray(sessionState.payload.agents)).toBe(true);
|
expect(Array.isArray(agentList.payload.agents)).toBe(true);
|
||||||
|
|
||||||
const listResult = await ctx.client.listVoiceConversations();
|
const listResult = await ctx.client.listVoiceConversations();
|
||||||
expect(Array.isArray(listResult.conversations)).toBe(true);
|
expect(Array.isArray(listResult.conversations)).toBe(true);
|
||||||
@@ -128,9 +128,12 @@ describe("daemon client v2 E2E", () => {
|
|||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
const agentStatePromise = waitForSignal(15000, (resolve) => {
|
const agentUpdatePromise = waitForSignal(15000, (resolve) => {
|
||||||
const unsubscribe = ctx.client.on("agent_state", (message) => {
|
const unsubscribe = ctx.client.on("agent_update", (message) => {
|
||||||
if (message.type !== "agent_state") {
|
if (message.type !== "agent_update") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.payload.kind !== "upsert") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(message);
|
resolve(message);
|
||||||
@@ -173,8 +176,8 @@ describe("daemon client v2 E2E", () => {
|
|||||||
ctx.client.listAgents().some((entry) => entry.id === agent.id)
|
ctx.client.listAgents().some((entry) => entry.id === agent.id)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
const agentState = await agentStatePromise;
|
const agentUpdate = await agentUpdatePromise;
|
||||||
expect(agentState.payload.id).toBe(agent.id);
|
expect(agentUpdate.payload.agent.id).toBe(agent.id);
|
||||||
const createdStatus = await createdStatusPromise;
|
const createdStatus = await createdStatusPromise;
|
||||||
expect(
|
expect(
|
||||||
(createdStatus.payload as { agentId?: string }).agentId
|
(createdStatus.payload as { agentId?: string }).agentId
|
||||||
@@ -253,7 +256,7 @@ describe("daemon client v2 E2E", () => {
|
|||||||
|
|
||||||
if (nextMode) {
|
if (nextMode) {
|
||||||
await ctx.client.setAgentMode(agent.id, nextMode);
|
await ctx.client.setAgentMode(agent.id, nextMode);
|
||||||
const modeState = await ctx.client.waitForAgentState(
|
const modeState = await ctx.client.waitForAgentUpsert(
|
||||||
agent.id,
|
agent.id,
|
||||||
(snapshot) => snapshot.currentModeId === nextMode,
|
(snapshot) => snapshot.currentModeId === nextMode,
|
||||||
15000
|
15000
|
||||||
|
|||||||
@@ -156,8 +156,12 @@ describe("daemon E2E", () => {
|
|||||||
const queue = ctx.client.getMessageQueue();
|
const queue = ctx.client.getMessageQueue();
|
||||||
for (let i = startPosition; i < queue.length; i++) {
|
for (let i = startPosition; i < queue.length; i++) {
|
||||||
const msg = queue[i];
|
const msg = queue[i];
|
||||||
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
|
if (
|
||||||
if (msg.payload.status === "running") {
|
msg.type === "agent_update" &&
|
||||||
|
msg.payload.kind === "upsert" &&
|
||||||
|
msg.payload.agent.id === agent.id
|
||||||
|
) {
|
||||||
|
if (msg.payload.agent.status === "running") {
|
||||||
sawRunning = true;
|
sawRunning = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve();
|
resolve();
|
||||||
@@ -206,14 +210,18 @@ describe("daemon E2E", () => {
|
|||||||
const queue = ctx.client.getMessageQueue();
|
const queue = ctx.client.getMessageQueue();
|
||||||
for (let i = queueStart; i < queue.length; i++) {
|
for (let i = queueStart; i < queue.length; i++) {
|
||||||
const msg = queue[i];
|
const msg = queue[i];
|
||||||
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
|
if (
|
||||||
|
msg.type === "agent_update" &&
|
||||||
|
msg.payload.kind === "upsert" &&
|
||||||
|
msg.payload.agent.id === agent.id
|
||||||
|
) {
|
||||||
if (
|
if (
|
||||||
msg.payload.status === "idle" ||
|
msg.payload.agent.status === "idle" ||
|
||||||
msg.payload.status === "error"
|
msg.payload.agent.status === "error"
|
||||||
) {
|
) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
resolve(msg.payload);
|
resolve(msg.payload.agent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,11 +292,11 @@ describe("daemon E2E", () => {
|
|||||||
// Switch to "read-only" mode
|
// Switch to "read-only" mode
|
||||||
await ctx.client.setAgentMode(agent.id, "read-only");
|
await ctx.client.setAgentMode(agent.id, "read-only");
|
||||||
|
|
||||||
// Wait for agent_state update reflecting the new mode
|
// Wait for agent_update upsert reflecting the new mode
|
||||||
const stateAfterModeSwitch = await new Promise<AgentSnapshotPayload>(
|
const stateAfterModeSwitch = await new Promise<AgentSnapshotPayload>(
|
||||||
(resolve, reject) => {
|
(resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
reject(new Error("Timeout waiting for mode change in agent_state"));
|
reject(new Error("Timeout waiting for mode change in agent_update"));
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
const checkForModeChange = (): void => {
|
const checkForModeChange = (): void => {
|
||||||
@@ -296,13 +304,14 @@ describe("daemon E2E", () => {
|
|||||||
for (let i = startPosition; i < queue.length; i++) {
|
for (let i = startPosition; i < queue.length; i++) {
|
||||||
const msg = queue[i];
|
const msg = queue[i];
|
||||||
if (
|
if (
|
||||||
msg.type === "agent_state" &&
|
msg.type === "agent_update" &&
|
||||||
msg.payload.id === agent.id &&
|
msg.payload.kind === "upsert" &&
|
||||||
msg.payload.currentModeId === "read-only"
|
msg.payload.agent.id === agent.id &&
|
||||||
|
msg.payload.agent.currentModeId === "read-only"
|
||||||
) {
|
) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
resolve(msg.payload);
|
resolve(msg.payload.agent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,7 +342,7 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
await ctx.client.setAgentMode(agent.id, "full-access");
|
await ctx.client.setAgentMode(agent.id, "full-access");
|
||||||
|
|
||||||
// Wait for agent_state update
|
// Wait for agent_update upsert
|
||||||
const stateAfterFullAccess = await new Promise<AgentSnapshotPayload>(
|
const stateAfterFullAccess = await new Promise<AgentSnapshotPayload>(
|
||||||
(resolve, reject) => {
|
(resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -345,13 +354,14 @@ describe("daemon E2E", () => {
|
|||||||
for (let i = position2; i < queue.length; i++) {
|
for (let i = position2; i < queue.length; i++) {
|
||||||
const msg = queue[i];
|
const msg = queue[i];
|
||||||
if (
|
if (
|
||||||
msg.type === "agent_state" &&
|
msg.type === "agent_update" &&
|
||||||
msg.payload.id === agent.id &&
|
msg.payload.kind === "upsert" &&
|
||||||
msg.payload.currentModeId === "full-access"
|
msg.payload.agent.id === agent.id &&
|
||||||
|
msg.payload.agent.currentModeId === "full-access"
|
||||||
) {
|
) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
resolve(msg.payload);
|
resolve(msg.payload.agent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ async function testMultiAgentSequence() {
|
|||||||
// Subscribe to all events for debugging
|
// Subscribe to all events for debugging
|
||||||
const unsub = client.subscribe((event) => {
|
const unsub = client.subscribe((event) => {
|
||||||
console.log(`[Event] type=${event.type}`);
|
console.log(`[Event] type=${event.type}`);
|
||||||
if (event.type === "session_state") {
|
if (event.type === "agent_list") {
|
||||||
console.log(` ${event.agents.length} agents`);
|
console.log(` ${event.agents.length} agents`);
|
||||||
agents.length = 0;
|
agents.length = 0;
|
||||||
for (const a of event.agents) {
|
for (const a of event.agents) {
|
||||||
@@ -57,8 +57,8 @@ async function testMultiAgentSequence() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Also log ALL raw messages
|
// Also log ALL raw messages
|
||||||
client.on("session_state", (msg: any) => {
|
client.on("agent_list", (msg: any) => {
|
||||||
console.log(`[RAW session_state] agents=${msg.agents?.length}`);
|
console.log(`[RAW agent_list] agents=${msg.agents?.length}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also log raw messages for debugging
|
// Also log raw messages for debugging
|
||||||
@@ -76,12 +76,12 @@ async function testMultiAgentSequence() {
|
|||||||
console.log("Connected to daemon");
|
console.log("Connected to daemon");
|
||||||
console.log(`Connection state: ${JSON.stringify(client.getConnectionState())}`);
|
console.log(`Connection state: ${JSON.stringify(client.getConnectionState())}`);
|
||||||
|
|
||||||
// Request session state (the app does this after connecting)
|
// Request agent list (the app does this after connecting)
|
||||||
console.log("Requesting session state...");
|
console.log("Requesting agent list...");
|
||||||
client.requestSessionState();
|
client.requestAgentList();
|
||||||
|
|
||||||
// Wait a bit for session state to arrive
|
// Wait a bit for agent list to arrive
|
||||||
console.log("Waiting 3s for session state...");
|
console.log("Waiting 3s for agent list...");
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
|
||||||
if (agents.length === 0) {
|
if (agents.length === 0) {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ describe("daemon E2E", () => {
|
|||||||
await ctx.client.sendMessage(agent.id, "List the files in the current directory.");
|
await ctx.client.sendMessage(agent.id, "List the files in the current directory.");
|
||||||
|
|
||||||
// Wait for agent to start running
|
// Wait for agent to start running
|
||||||
await ctx.client.waitForAgentState(
|
await ctx.client.waitForAgentUpsert(
|
||||||
agent.id,
|
agent.id,
|
||||||
(snapshot) => snapshot.status === "running",
|
(snapshot) => snapshot.status === "running",
|
||||||
10000
|
10000
|
||||||
@@ -192,7 +192,7 @@ describe("daemon E2E", () => {
|
|||||||
// Wait for agent to become idle after cancellation
|
// Wait for agent to become idle after cancellation
|
||||||
// Don't use waitForAgentIdle because it requires seeing "running" first,
|
// Don't use waitForAgentIdle because it requires seeing "running" first,
|
||||||
// but we already saw it above. Just wait for "idle" or "error".
|
// but we already saw it above. Just wait for "idle" or "error".
|
||||||
await ctx.client.waitForAgentState(
|
await ctx.client.waitForAgentUpsert(
|
||||||
agent.id,
|
agent.id,
|
||||||
(snapshot) =>
|
(snapshot) =>
|
||||||
snapshot.status === "idle" || snapshot.status === "error",
|
snapshot.status === "idle" || snapshot.status === "error",
|
||||||
@@ -317,7 +317,7 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
await ctx.client.setAgentMode(agent.id, "full-access");
|
await ctx.client.setAgentMode(agent.id, "full-access");
|
||||||
|
|
||||||
// Wait for mode change to be reflected in agent_state
|
// Wait for mode change to be reflected in agent_update
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
reject(new Error("Timeout waiting for full-access mode change"));
|
reject(new Error("Timeout waiting for full-access mode change"));
|
||||||
@@ -328,9 +328,10 @@ describe("daemon E2E", () => {
|
|||||||
for (let i = modeStartPosition; i < queue.length; i++) {
|
for (let i = modeStartPosition; i < queue.length; i++) {
|
||||||
const msg = queue[i];
|
const msg = queue[i];
|
||||||
if (
|
if (
|
||||||
msg.type === "agent_state" &&
|
msg.type === "agent_update" &&
|
||||||
msg.payload.id === agent.id &&
|
msg.payload.kind === "upsert" &&
|
||||||
msg.payload.currentModeId === "full-access"
|
msg.payload.agent.id === agent.id &&
|
||||||
|
msg.payload.agent.currentModeId === "full-access"
|
||||||
) {
|
) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
|||||||
@@ -381,7 +381,13 @@ describe("daemon E2E", () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transcriptFile = findTranscriptFile(sessionsDir, sessionId!);
|
let transcriptFile: string | null = null;
|
||||||
|
const transcriptWaitStart = Date.now();
|
||||||
|
while (Date.now() - transcriptWaitStart < 10000) {
|
||||||
|
transcriptFile = findTranscriptFile(sessionsDir, sessionId!);
|
||||||
|
if (transcriptFile) break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
|
||||||
// Verify transcript file exists
|
// Verify transcript file exists
|
||||||
expect(transcriptFile).not.toBeNull();
|
expect(transcriptFile).not.toBeNull();
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
|
||||||
|
describe("self-id MCP e2e", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
test("UI agent can call set_title to change its title", async () => {
|
||||||
|
// Create a Claude agent with ui=true label (triggers MCP injection)
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: "/tmp",
|
||||||
|
title: "Initial Title",
|
||||||
|
labels: { ui: "true" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.title).toBe("Initial Title");
|
||||||
|
|
||||||
|
// Send a message asking the agent to call set_title
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Use the set_title MCP tool to change your title to 'Updated via MCP'. Only call set_title, nothing else."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for agent to complete (MCP tools may auto-approve without permission)
|
||||||
|
// If a permission is requested, approve it
|
||||||
|
let finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
// Check if we got blocked on permission
|
||||||
|
if (finalState.status === "running" && finalState.pendingPermissions?.length) {
|
||||||
|
const permission = finalState.pendingPermissions[0];
|
||||||
|
await ctx.client.respondToPermission(agent.id, permission.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
});
|
||||||
|
finalState = await ctx.client.waitForAgentIdle(agent.id, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log final state for debugging if not idle
|
||||||
|
if (finalState.status !== "idle") {
|
||||||
|
console.error(
|
||||||
|
"Agent did not reach idle state:",
|
||||||
|
JSON.stringify(finalState, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
expect(finalState.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify the title was changed via set_title
|
||||||
|
expect(finalState.title).toBe("Updated via MCP");
|
||||||
|
}, 180000);
|
||||||
|
});
|
||||||
@@ -348,7 +348,7 @@ describeWithClaude("daemon E2E", () => {
|
|||||||
let lastState: AgentSnapshotPayload | null = null;
|
let lastState: AgentSnapshotPayload | null = null;
|
||||||
|
|
||||||
while (Date.now() - startTime < maxWaitMs) {
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
// Check agent_state messages in the queue
|
// Check agent_update upserts in the queue
|
||||||
const queue = ctx.client.getMessageQueue();
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
|
||||||
// Look for pattern: user_message (msg2) -> ... -> running -> ... -> idle/error
|
// Look for pattern: user_message (msg2) -> ... -> running -> ... -> idle/error
|
||||||
@@ -368,13 +368,21 @@ describeWithClaude("daemon E2E", () => {
|
|||||||
sawMsg2UserMessage = true;
|
sawMsg2UserMessage = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
|
if (
|
||||||
if (sawMsg2UserMessage && msg.payload.status === "running") {
|
msg.type === "agent_update" &&
|
||||||
|
msg.payload.kind === "upsert" &&
|
||||||
|
msg.payload.agent.id === agent.id
|
||||||
|
) {
|
||||||
|
if (sawMsg2UserMessage && msg.payload.agent.status === "running") {
|
||||||
sawRunningAfterMsg2 = true;
|
sawRunningAfterMsg2 = true;
|
||||||
}
|
}
|
||||||
if (sawRunningAfterMsg2 && (msg.payload.status === "idle" || msg.payload.status === "error")) {
|
if (
|
||||||
|
sawRunningAfterMsg2 &&
|
||||||
|
(msg.payload.agent.status === "idle" ||
|
||||||
|
msg.payload.agent.status === "error")
|
||||||
|
) {
|
||||||
sawIdleAfterRunning = true;
|
sawIdleAfterRunning = true;
|
||||||
lastState = msg.payload;
|
lastState = msg.payload.agent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,8 +510,12 @@ describeWithClaude("daemon E2E", () => {
|
|||||||
|
|
||||||
// Subscribe to all messages for logging
|
// Subscribe to all messages for logging
|
||||||
const unsubscribe = ctx.client.on((event) => {
|
const unsubscribe = ctx.client.on((event) => {
|
||||||
if (event.type === "agent_state" && event.agentId === agent.id) {
|
if (
|
||||||
log(`[EVENT] agent_state: status=${event.payload.status}`);
|
event.type === "agent_update" &&
|
||||||
|
event.agentId === agent.id &&
|
||||||
|
event.payload.kind === "upsert"
|
||||||
|
) {
|
||||||
|
log(`[EVENT] agent_update: status=${event.payload.agent.status}`);
|
||||||
} else if (event.type === "agent_stream" && event.agentId === agent.id) {
|
} else if (event.type === "agent_stream" && event.agentId === agent.id) {
|
||||||
const evt = event.event;
|
const evt = event.event;
|
||||||
if (evt.type === "timeline") {
|
if (evt.type === "timeline") {
|
||||||
@@ -564,8 +576,12 @@ describeWithClaude("daemon E2E", () => {
|
|||||||
const queueBeforeStop = ctx.client.getMessageQueue();
|
const queueBeforeStop = ctx.client.getMessageQueue();
|
||||||
let lastStatus = "unknown";
|
let lastStatus = "unknown";
|
||||||
for (const msg of queueBeforeStop) {
|
for (const msg of queueBeforeStop) {
|
||||||
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
|
if (
|
||||||
lastStatus = msg.payload.status;
|
msg.type === "agent_update" &&
|
||||||
|
msg.payload.kind === "upsert" &&
|
||||||
|
msg.payload.agent.id === agent.id
|
||||||
|
) {
|
||||||
|
lastStatus = msg.payload.agent.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log(`agent status before Stop: ${lastStatus}`);
|
log(`agent status before Stop: ${lastStatus}`);
|
||||||
@@ -670,9 +686,13 @@ describeWithClaude("daemon E2E", () => {
|
|||||||
|
|
||||||
for (let i = startPosition; i < queue.length; i++) {
|
for (let i = startPosition; i < queue.length; i++) {
|
||||||
const m = queue[i];
|
const m = queue[i];
|
||||||
if (m.type === "agent_state" && m.payload.id === agent.id) {
|
if (
|
||||||
if (m.payload.status === "running") currentRunningCount++;
|
m.type === "agent_update" &&
|
||||||
lastState = m.payload;
|
m.payload.kind === "upsert" &&
|
||||||
|
m.payload.agent.id === agent.id
|
||||||
|
) {
|
||||||
|
if (m.payload.agent.status === "running") currentRunningCount++;
|
||||||
|
lastState = m.payload.agent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,8 +715,12 @@ describeWithClaude("daemon E2E", () => {
|
|||||||
|
|
||||||
for (let i = startPosition; i < queue.length; i++) {
|
for (let i = startPosition; i < queue.length; i++) {
|
||||||
const m = queue[i];
|
const m = queue[i];
|
||||||
if (m.type === "agent_state" && m.payload.id === agent.id) {
|
if (
|
||||||
stateChanges.push(m.payload.status);
|
m.type === "agent_update" &&
|
||||||
|
m.payload.kind === "upsert" &&
|
||||||
|
m.payload.agent.id === agent.id
|
||||||
|
) {
|
||||||
|
stateChanges.push(m.payload.agent.status);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
m.type === "agent_stream" &&
|
m.type === "agent_stream" &&
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { open, readFile, unlink, mkdir } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { hostname } from "node:os";
|
||||||
|
|
||||||
|
export interface PidLockInfo {
|
||||||
|
pid: number;
|
||||||
|
startedAt: string;
|
||||||
|
hostname: string;
|
||||||
|
uid: number;
|
||||||
|
sockPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PidLockError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly existingLock?: PidLockInfo
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PidLockError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPidRunning(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPidFilePath(paseoHome: string): string {
|
||||||
|
return join(paseoHome, "paseo.pid");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquirePidLock(
|
||||||
|
paseoHome: string,
|
||||||
|
sockPath: string
|
||||||
|
): Promise<void> {
|
||||||
|
const pidPath = getPidFilePath(paseoHome);
|
||||||
|
|
||||||
|
// Ensure paseoHome directory exists
|
||||||
|
if (!existsSync(paseoHome)) {
|
||||||
|
await mkdir(paseoHome, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read existing lock
|
||||||
|
let existingLock: PidLockInfo | null = null;
|
||||||
|
try {
|
||||||
|
const content = await readFile(pidPath, "utf-8");
|
||||||
|
existingLock = JSON.parse(content) as PidLockInfo;
|
||||||
|
} catch {
|
||||||
|
// No existing lock or invalid JSON - that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if existing lock is stale
|
||||||
|
if (existingLock) {
|
||||||
|
if (isPidRunning(existingLock.pid)) {
|
||||||
|
throw new PidLockError(
|
||||||
|
`Another Paseo daemon is already running (PID ${existingLock.pid}, started ${existingLock.startedAt})`,
|
||||||
|
existingLock
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Stale lock - remove it
|
||||||
|
await unlink(pidPath).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new lock with exclusive flag
|
||||||
|
const lockInfo: PidLockInfo = {
|
||||||
|
pid: process.pid,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
hostname: hostname(),
|
||||||
|
uid: process.getuid?.() ?? 0,
|
||||||
|
sockPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fd;
|
||||||
|
try {
|
||||||
|
fd = await open(pidPath, "wx");
|
||||||
|
await fd.write(JSON.stringify(lockInfo));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "EEXIST") {
|
||||||
|
// Race condition - another process created the file
|
||||||
|
// Re-read and check
|
||||||
|
try {
|
||||||
|
const content = await readFile(pidPath, "utf-8");
|
||||||
|
const raceLock = JSON.parse(content) as PidLockInfo;
|
||||||
|
throw new PidLockError(
|
||||||
|
`Another Paseo daemon is already running (PID ${raceLock.pid})`,
|
||||||
|
raceLock
|
||||||
|
);
|
||||||
|
} catch (innerErr) {
|
||||||
|
if (innerErr instanceof PidLockError) throw innerErr;
|
||||||
|
throw new PidLockError("Failed to acquire PID lock due to race condition");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await fd?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releasePidLock(paseoHome: string): Promise<void> {
|
||||||
|
const pidPath = getPidFilePath(paseoHome);
|
||||||
|
try {
|
||||||
|
// Only remove if it's our lock
|
||||||
|
const content = await readFile(pidPath, "utf-8");
|
||||||
|
const lock = JSON.parse(content) as PidLockInfo;
|
||||||
|
if (lock.pid === process.pid) {
|
||||||
|
await unlink(pidPath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors - lock may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPidLockInfo(
|
||||||
|
paseoHome: string
|
||||||
|
): Promise<PidLockInfo | null> {
|
||||||
|
const pidPath = getPidFilePath(paseoHome);
|
||||||
|
try {
|
||||||
|
const content = await readFile(pidPath, "utf-8");
|
||||||
|
return JSON.parse(content) as PidLockInfo;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isLocked(
|
||||||
|
paseoHome: string
|
||||||
|
): Promise<{ locked: boolean; info?: PidLockInfo }> {
|
||||||
|
const info = await getPidLockInfo(paseoHome);
|
||||||
|
if (!info) {
|
||||||
|
return { locked: false };
|
||||||
|
}
|
||||||
|
if (!isPidRunning(info.pid)) {
|
||||||
|
return { locked: false, info };
|
||||||
|
}
|
||||||
|
return { locked: true, info };
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { readFile, mkdir, writeFile, stat } from "fs/promises";
|
|||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify, inspect } from "util";
|
import { promisify, inspect } from "util";
|
||||||
import { join, resolve, sep } from "path";
|
import { join, resolve, sep } from "path";
|
||||||
|
import http from "http";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { streamText, stepCountIs } from "ai";
|
import { streamText, stepCountIs } from "ai";
|
||||||
@@ -290,10 +291,17 @@ export class Session {
|
|||||||
private agentManager: AgentManager;
|
private agentManager: AgentManager;
|
||||||
private readonly agentStorage: AgentStorage;
|
private readonly agentStorage: AgentStorage;
|
||||||
private readonly agentMcpRoute: string;
|
private readonly agentMcpRoute: string;
|
||||||
|
private readonly mcpSocketPath: string;
|
||||||
private readonly downloadTokenStore: DownloadTokenStore;
|
private readonly downloadTokenStore: DownloadTokenStore;
|
||||||
private readonly pushTokenStore: PushTokenStore;
|
private readonly pushTokenStore: PushTokenStore;
|
||||||
private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>;
|
private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>;
|
||||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||||
|
private agentUpdatesSubscription:
|
||||||
|
| {
|
||||||
|
subscriptionId: string;
|
||||||
|
filter?: { labels?: Record<string, string> };
|
||||||
|
}
|
||||||
|
| null = null;
|
||||||
private clientActivity: {
|
private clientActivity: {
|
||||||
deviceType: "web" | "mobile";
|
deviceType: "web" | "mobile";
|
||||||
focusedAgentId: string | null;
|
focusedAgentId: string | null;
|
||||||
@@ -313,6 +321,7 @@ export class Session {
|
|||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentStorage: AgentStorage,
|
agentStorage: AgentStorage,
|
||||||
agentMcpRoute: string,
|
agentMcpRoute: string,
|
||||||
|
mcpSocketPath: string,
|
||||||
stt: OpenAISTT | null,
|
stt: OpenAISTT | null,
|
||||||
tts: OpenAITTS | null,
|
tts: OpenAITTS | null,
|
||||||
terminalManager: TerminalManager | null,
|
terminalManager: TerminalManager | null,
|
||||||
@@ -327,6 +336,7 @@ export class Session {
|
|||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.agentStorage = agentStorage;
|
this.agentStorage = agentStorage;
|
||||||
this.agentMcpRoute = agentMcpRoute;
|
this.agentMcpRoute = agentMcpRoute;
|
||||||
|
this.mcpSocketPath = mcpSocketPath;
|
||||||
this.terminalManager = terminalManager;
|
this.terminalManager = terminalManager;
|
||||||
this.voiceConversationStore = voiceConversationStore;
|
this.voiceConversationStore = voiceConversationStore;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
@@ -369,7 +379,7 @@ export class Session {
|
|||||||
* Send initial state to client after connection
|
* Send initial state to client after connection
|
||||||
*/
|
*/
|
||||||
public async sendInitialState(): Promise<void> {
|
public async sendInitialState(): Promise<void> {
|
||||||
await this.sendSessionState();
|
await this.sendAgentList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -500,10 +510,56 @@ export class Session {
|
|||||||
*/
|
*/
|
||||||
private async initializeAgentMcp(): Promise<void> {
|
private async initializeAgentMcp(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Connect to the local MCP server using localhost
|
// Create a custom fetch that uses the Unix socket
|
||||||
const agentMcpUrl = `http://127.0.0.1:6767${this.agentMcpRoute}`;
|
const socketFetch = async (
|
||||||
|
input: string | URL,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<Response> => {
|
||||||
|
const url = new URL(input.toString());
|
||||||
|
const path = url.pathname + url.search;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
socketPath: this.mcpSocketPath,
|
||||||
|
path,
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
headers: {
|
||||||
|
...Object.fromEntries(
|
||||||
|
new Headers(init?.headers).entries()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res: import("http").IncomingMessage) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
resolve(
|
||||||
|
new Response(body, {
|
||||||
|
status: res.statusCode ?? 500,
|
||||||
|
statusText: res.statusMessage ?? "",
|
||||||
|
headers: new Headers(
|
||||||
|
res.headers as Record<string, string>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
res.on("error", reject);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
if (init?.body) {
|
||||||
|
req.write(init.body);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect to the local MCP server using Unix socket
|
||||||
const transport = new StreamableHTTPClientTransport(
|
const transport = new StreamableHTTPClientTransport(
|
||||||
new URL(agentMcpUrl)
|
new URL(`http://localhost${this.agentMcpRoute}`),
|
||||||
|
{ fetch: socketFetch as typeof fetch }
|
||||||
);
|
);
|
||||||
|
|
||||||
this.agentMcpClient = await experimental_createMCPClient({
|
this.agentMcpClient = await experimental_createMCPClient({
|
||||||
@@ -535,7 +591,7 @@ export class Session {
|
|||||||
this.unsubscribeAgentEvents = this.agentManager.subscribe(
|
this.unsubscribeAgentEvents = this.agentManager.subscribe(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (event.type === "agent_state") {
|
if (event.type === "agent_state") {
|
||||||
void this.forwardAgentState(event.agent);
|
void this.forwardAgentUpdate(event.agent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,18 +730,42 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async forwardAgentState(agent: ManagedAgent): Promise<void> {
|
private matchesAgentFilter(
|
||||||
|
agent: AgentSnapshotPayload,
|
||||||
|
filter?: { labels?: Record<string, string> }
|
||||||
|
): boolean {
|
||||||
|
if (!filter?.labels) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Object.entries(filter.labels).every(
|
||||||
|
([key, value]) => agent.labels[key] === value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const subscription = this.agentUpdatesSubscription;
|
||||||
|
if (!subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await this.buildAgentPayload(agent);
|
const payload = await this.buildAgentPayload(agent);
|
||||||
|
const matches = this.matchesAgentFilter(payload, subscription.filter);
|
||||||
|
|
||||||
|
if (matches) {
|
||||||
|
this.emit({
|
||||||
|
type: "agent_update",
|
||||||
|
payload: { kind: "upsert", agent: payload },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "agent_state",
|
type: "agent_update",
|
||||||
payload,
|
payload: { kind: "remove", agentId: payload.id },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error({ err: error }, "Failed to emit agent update");
|
||||||
{ err: error },
|
|
||||||
"Failed to emit agent state"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,8 +791,23 @@ export class Session {
|
|||||||
this.handleAudioPlayed(msg.id);
|
this.handleAudioPlayed(msg.id);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "request_session_state":
|
case "request_agent_list":
|
||||||
await this.sendSessionState();
|
await this.sendAgentList(msg.filter);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "subscribe_agent_updates":
|
||||||
|
this.agentUpdatesSubscription = {
|
||||||
|
subscriptionId: msg.subscriptionId,
|
||||||
|
filter: msg.filter,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "unsubscribe_agent_updates":
|
||||||
|
if (
|
||||||
|
this.agentUpdatesSubscription?.subscriptionId === msg.subscriptionId
|
||||||
|
) {
|
||||||
|
this.agentUpdatesSubscription = null;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "load_voice_conversation_request":
|
case "load_voice_conversation_request":
|
||||||
@@ -966,8 +1061,8 @@ export class Session {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send current session state (live agents and commands)
|
// Send current agent list
|
||||||
await this.sendSessionState();
|
await this.sendAgentList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1117,6 +1212,13 @@ export class Session {
|
|||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.agentUpdatesSubscription) {
|
||||||
|
this.emit({
|
||||||
|
type: "agent_update",
|
||||||
|
payload: { kind: "remove", agentId },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleArchiveAgentRequest(
|
private async handleArchiveAgentRequest(
|
||||||
@@ -1275,7 +1377,7 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await this.ensureAgentLoaded(agentId);
|
const snapshot = await this.ensureAgentLoaded(agentId);
|
||||||
await this.forwardAgentState(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
|
|
||||||
// Send timeline snapshot after hydration (if any)
|
// Send timeline snapshot after hydration (if any)
|
||||||
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
|
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
|
||||||
@@ -1354,7 +1456,7 @@ export class Session {
|
|||||||
undefined,
|
undefined,
|
||||||
{ labels }
|
{ labels }
|
||||||
);
|
);
|
||||||
await this.forwardAgentState(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
|
|
||||||
const trimmedPrompt = initialPrompt?.trim();
|
const trimmedPrompt = initialPrompt?.trim();
|
||||||
if (trimmedPrompt) {
|
if (trimmedPrompt) {
|
||||||
@@ -1459,7 +1561,7 @@ export class Session {
|
|||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
await this.agentManager.primeAgentHistory(snapshot.id);
|
await this.agentManager.primeAgentHistory(snapshot.id);
|
||||||
await this.forwardAgentState(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
|
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -1529,7 +1631,7 @@ export class Session {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.agentManager.primeAgentHistory(agentId);
|
await this.agentManager.primeAgentHistory(agentId);
|
||||||
await this.forwardAgentState(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
|
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -3403,9 +3505,9 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send current session state (live agents and commands) to client
|
* Send agent list to client, optionally filtered by labels
|
||||||
*/
|
*/
|
||||||
private async sendSessionState(): Promise<void> {
|
private async sendAgentList(filter?: { labels?: Record<string, string> }): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Get live agents with session modes
|
// Get live agents with session modes
|
||||||
const agentSnapshots = this.agentManager.listAgents();
|
const agentSnapshots = this.agentManager.listAgents();
|
||||||
@@ -3421,24 +3523,32 @@ export class Session {
|
|||||||
.filter((record) => !liveIds.has(record.id) && !record.internal)
|
.filter((record) => !liveIds.has(record.id) && !record.internal)
|
||||||
.map((record) => this.buildStoredAgentPayload(record));
|
.map((record) => this.buildStoredAgentPayload(record));
|
||||||
|
|
||||||
const agents = [...liveAgents, ...persistedAgents];
|
let agents = [...liveAgents, ...persistedAgents];
|
||||||
|
|
||||||
// Emit session state
|
// Filter by labels if filter provided
|
||||||
|
if (filter?.labels) {
|
||||||
|
const filterLabels = filter.labels;
|
||||||
|
agents = agents.filter((agent) =>
|
||||||
|
Object.entries(filterLabels).every(([key, value]) => agent.labels[key] === value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit agent list
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "session_state",
|
type: "agent_list",
|
||||||
payload: {
|
payload: {
|
||||||
agents,
|
agents,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sessionLogger.debug(
|
this.sessionLogger.debug(
|
||||||
{ agentCount: agents.length },
|
{ agentCount: agents.length, filter },
|
||||||
`Sent session state: ${agents.length} agents`
|
`Sent agent list: ${agents.length} agents`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error(
|
||||||
{ err: error },
|
{ err: error },
|
||||||
"Failed to send session state"
|
"Failed to send agent list"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export async function createDaemonTestContext(
|
|||||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
client.subscribeAgentUpdates({ subscriptionId: "test" });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
daemon,
|
daemon,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export async function createTestPaseoDaemon(
|
|||||||
const config: PaseoDaemonConfig = {
|
const config: PaseoDaemonConfig = {
|
||||||
listen: `${listenHost}:${port}`,
|
listen: `${listenHost}:${port}`,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
|
selfIdMcpSocketPath: path.join(paseoHome, "self-id-mcp.sock"),
|
||||||
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
||||||
agentMcpRoute: "/mcp/agents",
|
agentMcpRoute: "/mcp/agents",
|
||||||
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`, `${listenHost}:${port}`],
|
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`, `${listenHost}:${port}`],
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
|||||||
lastUsage: AgentUsageSchema.optional(),
|
lastUsage: AgentUsageSchema.optional(),
|
||||||
lastError: z.string().optional(),
|
lastError: z.string().optional(),
|
||||||
title: z.string().nullable(),
|
title: z.string().nullable(),
|
||||||
labels: z.record(z.string()).optional(),
|
labels: z.record(z.string()).default({}),
|
||||||
requiresAttention: z.boolean().optional(),
|
requiresAttention: z.boolean().optional(),
|
||||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||||
attentionTimestamp: z.string().nullable().optional(),
|
attentionTimestamp: z.string().nullable().optional(),
|
||||||
@@ -279,9 +279,25 @@ export const AudioPlayedMessageSchema = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RequestSessionStateMessageSchema = z.object({
|
export const RequestAgentListMessageSchema = z.object({
|
||||||
type: z.literal("request_session_state"),
|
type: z.literal("request_agent_list"),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
|
filter: z.object({
|
||||||
|
labels: z.record(z.string()).optional(),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SubscribeAgentUpdatesMessageSchema = z.object({
|
||||||
|
type: z.literal("subscribe_agent_updates"),
|
||||||
|
subscriptionId: z.string(),
|
||||||
|
filter: z.object({
|
||||||
|
labels: z.record(z.string()).optional(),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UnsubscribeAgentUpdatesMessageSchema = z.object({
|
||||||
|
type: z.literal("unsubscribe_agent_updates"),
|
||||||
|
subscriptionId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LoadVoiceConversationRequestMessageSchema = z.object({
|
export const LoadVoiceConversationRequestMessageSchema = z.object({
|
||||||
@@ -379,7 +395,7 @@ export const CreateAgentRequestMessageSchema = z.object({
|
|||||||
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
|
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
|
||||||
})).optional(),
|
})).optional(),
|
||||||
git: GitSetupOptionsSchema.optional(),
|
git: GitSetupOptionsSchema.optional(),
|
||||||
labels: z.record(z.string()).optional(),
|
labels: z.record(z.string()).default({}),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -720,7 +736,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
RealtimeAudioChunkMessageSchema,
|
RealtimeAudioChunkMessageSchema,
|
||||||
AbortRequestMessageSchema,
|
AbortRequestMessageSchema,
|
||||||
AudioPlayedMessageSchema,
|
AudioPlayedMessageSchema,
|
||||||
RequestSessionStateMessageSchema,
|
RequestAgentListMessageSchema,
|
||||||
|
SubscribeAgentUpdatesMessageSchema,
|
||||||
|
UnsubscribeAgentUpdatesMessageSchema,
|
||||||
LoadVoiceConversationRequestMessageSchema,
|
LoadVoiceConversationRequestMessageSchema,
|
||||||
ListVoiceConversationsRequestMessageSchema,
|
ListVoiceConversationsRequestMessageSchema,
|
||||||
DeleteVoiceConversationRequestMessageSchema,
|
DeleteVoiceConversationRequestMessageSchema,
|
||||||
@@ -945,9 +963,18 @@ export const VoiceConversationLoadedMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AgentStateMessageSchema = z.object({
|
export const AgentUpdateMessageSchema = z.object({
|
||||||
type: z.literal("agent_state"),
|
type: z.literal("agent_update"),
|
||||||
payload: AgentSnapshotPayloadSchema,
|
payload: z.discriminatedUnion("kind", [
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("upsert"),
|
||||||
|
agent: AgentSnapshotPayloadSchema,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("remove"),
|
||||||
|
agentId: z.string(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AgentStreamMessageSchema = z.object({
|
export const AgentStreamMessageSchema = z.object({
|
||||||
@@ -981,8 +1008,8 @@ export const AgentStatusMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SessionStateMessageSchema = z.object({
|
export const AgentListMessageSchema = z.object({
|
||||||
type: z.literal("session_state"),
|
type: z.literal("agent_list"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
agents: z.array(AgentSnapshotPayloadSchema),
|
agents: z.array(AgentSnapshotPayloadSchema),
|
||||||
}),
|
}),
|
||||||
@@ -1419,11 +1446,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
InitializeAgentResponseMessageSchema,
|
InitializeAgentResponseMessageSchema,
|
||||||
ArtifactMessageSchema,
|
ArtifactMessageSchema,
|
||||||
VoiceConversationLoadedMessageSchema,
|
VoiceConversationLoadedMessageSchema,
|
||||||
AgentStateMessageSchema,
|
AgentUpdateMessageSchema,
|
||||||
AgentStreamMessageSchema,
|
AgentStreamMessageSchema,
|
||||||
AgentStreamSnapshotMessageSchema,
|
AgentStreamSnapshotMessageSchema,
|
||||||
AgentStatusMessageSchema,
|
AgentStatusMessageSchema,
|
||||||
SessionStateMessageSchema,
|
AgentListMessageSchema,
|
||||||
ListVoiceConversationsResponseMessageSchema,
|
ListVoiceConversationsResponseMessageSchema,
|
||||||
DeleteVoiceConversationResponseMessageSchema,
|
DeleteVoiceConversationResponseMessageSchema,
|
||||||
AgentPermissionRequestMessageSchema,
|
AgentPermissionRequestMessageSchema,
|
||||||
@@ -1470,13 +1497,13 @@ export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
|||||||
export type VoiceConversationLoadedMessage = z.infer<
|
export type VoiceConversationLoadedMessage = z.infer<
|
||||||
typeof VoiceConversationLoadedMessageSchema
|
typeof VoiceConversationLoadedMessageSchema
|
||||||
>;
|
>;
|
||||||
export type AgentStateMessage = z.infer<typeof AgentStateMessageSchema>;
|
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
|
||||||
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
||||||
export type AgentStreamSnapshotMessage = z.infer<
|
export type AgentStreamSnapshotMessage = z.infer<
|
||||||
typeof AgentStreamSnapshotMessageSchema
|
typeof AgentStreamSnapshotMessageSchema
|
||||||
>;
|
>;
|
||||||
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
||||||
export type SessionStateMessage = z.infer<typeof SessionStateMessageSchema>;
|
export type AgentListMessage = z.infer<typeof AgentListMessageSchema>;
|
||||||
export type ListVoiceConversationsResponseMessage = z.infer<
|
export type ListVoiceConversationsResponseMessage = z.infer<
|
||||||
typeof ListVoiceConversationsResponseMessageSchema
|
typeof ListVoiceConversationsResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
|
|||||||
Reference in New Issue
Block a user