/** * P0: Child sessions with permission derivation. * * Creates persisted child sessions in the session DB with full lifecycle * management. Permissions cascade from parent: parent's deny rules are * forwarded, and the child session gets a restricted permission set by default. */ import { v4 as uuidv4 } from "uuid"; export interface ChildSessionPermissions { /** Tools the child can call (subset of parent's permissions) */ tools: string[]; /** File patterns the child can read */ readPatterns: string[]; /** File patterns the child can write */ writePatterns: string[]; /** Shell commands the child can execute */ shellPatterns: string[]; /** Whether the child can spawn sub-agents */ canSpawnAgents: boolean; /** Whether the child can call task_complete */ canComplete: boolean; /** Whether the child can access the parent's session messages */ canAccessParentMessages: boolean; } export interface ChildSession { /** Unique session ID */ id: string; /** Parent session ID */ parentSessionId: string; /** Title/description of the child session */ title: string; /** Agent type used for this session */ agent: string; /** Permissions derived from parent */ permissions: ChildSessionPermissions; /** Whether the session is active */ isActive: boolean; /** When the session was created */ createdAt: string; /** When the session was last active */ updatedAt: string; /** Accumulated context/messages for this session */ messages: Array<{ role: string; content: string }>; /** Parent's deny rules that cascade to this child */ parentDenyRules: string[]; /** task_id for resumption */ taskId?: string; } export class ChildSessionManager { private sessions: Map = new Map(); /** * Create a new child session with permission derivation from parent. * Implements OpenCode's pattern: sessions.create({ parentID, title, agent, permission }) */ create( parentSessionId: string, title: string, agent: string, parentPermissions?: ChildSessionPermissions, parentDenyRules?: string[], ): ChildSession { const id = `child-${uuidv4()}`; const now = new Date().toISOString(); // Permission derivation: forward parent's denies, default-deny tool/task const permissions: ChildSessionPermissions = { tools: parentPermissions?.tools ?? [], readPatterns: parentPermissions?.readPatterns ?? ["**/*"], writePatterns: parentPermissions?.writePatterns ?? [], shellPatterns: parentPermissions?.shellPatterns ?? [], canSpawnAgents: false, // default-deny canComplete: false, // default-deny canAccessParentMessages: false, // default-deny }; // Forward parent's deny rules const denyRules = parentDenyRules ?? []; const session: ChildSession = { id, parentSessionId, title, agent, permissions, isActive: true, createdAt: now, updatedAt: now, messages: [], parentDenyRules: denyRules, }; this.sessions.set(id, session); return session; } /** * Get a child session by ID. */ get(id: string): ChildSession | undefined { return this.sessions.get(id); } /** * Check if a child session can perform an action based on its permissions. * Returns { allowed: boolean, reason?: string } */ checkPermission( sessionId: string, action: "read" | "write" | "shell" | "spawn" | "complete" | "accessMessages", target?: string, ): { allowed: boolean; reason?: string } { const session = this.sessions.get(sessionId); if (!session) { return { allowed: false, reason: "Session not found" }; } if (!session.isActive) { return { allowed: false, reason: "Session is inactive" }; } // Check parent deny rules first (they cascade) for (const rule of session.parentDenyRules) { if (this.matchesRule(rule, action, target)) { return { allowed: false, reason: `Denied by parent deny rule: ${rule}` }; } } // Check child permissions switch (action) { case "read": return { allowed: session.permissions.readPatterns.some((p) => this.matchesPattern(p, target ?? ""), ), reason: target ? `No matching read pattern for: ${target}` : undefined, }; case "write": return { allowed: session.permissions.writePatterns.some((p) => this.matchesPattern(p, target ?? ""), ), reason: target ? `No matching write pattern for: ${target}` : undefined, }; case "shell": return { allowed: session.permissions.shellPatterns.some((p) => this.matchesPattern(p, target ?? ""), ), reason: target ? `No matching shell pattern for: ${target}` : undefined, }; case "spawn": return { allowed: session.permissions.canSpawnAgents, reason: "Child sessions cannot spawn agents by default", }; case "complete": return { allowed: session.permissions.canComplete, reason: "Child sessions cannot call task_complete by default", }; case "accessMessages": return { allowed: session.permissions.canAccessParentMessages, reason: "Child sessions cannot access parent messages by default", }; default: return { allowed: false, reason: `Unknown action: ${action}` }; } } /** * Add messages to a child session. */ addMessage(sessionId: string, message: { role: string; content: string }): boolean { const session = this.sessions.get(sessionId); if (!session) return false; session.messages.push(message); session.updatedAt = new Date().toISOString(); return true; } /** * Close a child session. */ close(sessionId: string): boolean { const session = this.sessions.get(sessionId); if (!session) return false; session.isActive = false; return true; } /** * Get or create a child session — resumes by task_id if available, otherwise creates new. * Implements OpenCode's task_id resumption pattern: passing a prior task_id continues * the same subagent session with accumulated context. */ getOrCreateChildSession( parentSessionId: string, title: string, agent: string, parentPermissions?: ChildSessionPermissions, parentDenyRules?: string[], taskId?: string, ): ChildSession { // Try to resume by task_id first if (taskId) { const existing = this.resume(taskId); if (existing) { return existing; } } // Create new session const session = this.create(parentSessionId, title, agent, parentPermissions, parentDenyRules); // Assign task_id if provided if (taskId) { this.assignTaskId(session.id, taskId); } return session; } /** * List all active child sessions for a parent. */ listActive(parentSessionId: string): ChildSession[] { return Array.from(this.sessions.values()).filter( (s) => s.parentSessionId === parentSessionId && s.isActive, ); } /** * Resume a child session by task_id. * Implements OpenCode's task_id resumption pattern. */ resume(taskId: string): ChildSession | undefined { for (const session of this.sessions.values()) { if (session.taskId === taskId && session.isActive) { return session; } } return undefined; } /** * Assign a task_id to a child session for resumption. */ assignTaskId(sessionId: string, taskId: string): boolean { const session = this.sessions.get(sessionId); if (!session) return false; session.taskId = taskId; session.updatedAt = new Date().toISOString(); return true; } // --- Helpers --- private matchesRule(rule: string, action: string, target?: string): boolean { // Simple rule matching: "deny:read:*", "deny:write:*.env", etc. if (rule.startsWith("deny:")) { const [_, actionPattern, ...targetPatterns] = rule.split(":"); if (actionPattern !== action) return false; if (targetPatterns.length === 0) return true; // deny all for this action return targetPatterns.some((p) => this.matchesPattern(p, target ?? "")); } return false; } private matchesPattern(pattern: string, target: string): boolean { if (pattern === "*") return true; if (pattern.startsWith("**/")) { return target.endsWith(pattern.slice(3)); } if (pattern.endsWith("/*")) { return target.startsWith(pattern.slice(0, -2)); } return target === pattern; } }