Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@ public struct CLICommandConfiguration: Codable, Sendable {
]
}

// System-prompt text is per-invocation, not part of conversation
// history, so resume must re-pass it or the session silently loses the
// launch context / simulator guidance it started with.
if let appendSystemPrompt, !appendSystemPrompt.isEmpty {
providerArgs += ["--append-system-prompt", appendSystemPrompt]
}

// Add flags only for NEW sessions (not resume)
if isNewSession {
if permissionModePlan {
Expand All @@ -155,9 +162,6 @@ public struct CLICommandConfiguration: Codable, Sendable {
} else if dangerouslySkipPermissions {
providerArgs.append("--dangerously-skip-permissions")
}
if let appendSystemPrompt, !appendSystemPrompt.isEmpty {
providerArgs += ["--append-system-prompt", appendSystemPrompt]
}
if let name = worktreeName {
if name.isEmpty {
providerArgs.append("--worktree")
Expand Down Expand Up @@ -207,6 +211,12 @@ public struct CLICommandConfiguration: Codable, Sendable {
xcodeBuildMCP: xcodeBuildMCPBootstrap
)
}
// developer_instructions is a config override, not conversation
// history — resume must re-pass it or the session loses its launch
// context / simulator guidance.
if let appendSystemPrompt, !appendSystemPrompt.isEmpty {
providerArgs += ["-c", "developer_instructions=\(tomlStringLiteral(appendSystemPrompt))"]
}
return assembleArguments(prefix: prefix, providerArgs: providerArgs, trailingArgs: ["resume", sessionId])
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public struct PendingHubSession: Identifiable {
public let startedAt: Date
public let initialPrompt: String?
public let initialInputText: String?
/// Curated context delivered out-of-band (system-prompt channel), never
/// merged into the first user message.
public let launchContext: String?
public let dangerouslySkipPermissions: Bool
public let permissionModePlan: Bool
/// nil = no worktree flag; "" = --worktree (auto-name); non-empty = --worktree <name>
Expand All @@ -26,6 +29,7 @@ public struct PendingHubSession: Identifiable {
launchPath: String? = nil,
initialPrompt: String? = nil,
initialInputText: String? = nil,
launchContext: String? = nil,
dangerouslySkipPermissions: Bool = false,
permissionModePlan: Bool = false,
worktreeName: String? = nil
Expand All @@ -36,6 +40,7 @@ public struct PendingHubSession: Identifiable {
self.startedAt = Date()
self.initialPrompt = initialPrompt
self.initialInputText = initialInputText
self.launchContext = launchContext
self.dangerouslySkipPermissions = dangerouslySkipPermissions
self.permissionModePlan = permissionModePlan
self.worktreeName = worktreeName
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// SessionLaunchContextRecord.swift
// AgentHub
//
// SQLite record for the curated launch context attached to a session.
//
// Launch context is delivered out-of-band (Claude `--append-system-prompt`,
// Codex `-c developer_instructions=`) instead of inside the first user
// message, so it is not part of the conversation history and would be lost
// on resume. This row lets a resume launch re-pass the exact text the
// session started with.
//

import Foundation
import GRDB

public struct SessionLaunchContextRecord: Codable, Equatable, Sendable, FetchableRecord, PersistableRecord {
public var sessionId: String
public var provider: String
public var projectPath: String
/// The exact text passed at launch: either the inline `<context>` block or
/// the short file-reference prompt for oversized payloads.
public var contextText: String
public var createdAt: Date
public var updatedAt: Date

public static var databaseTableName: String { "session_launch_context" }

public init(
sessionId: String,
provider: String,
projectPath: String,
contextText: String,
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.sessionId = sessionId
self.provider = provider
self.projectPath = projectPath
self.contextText = contextText
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public actor SessionMetadataStore: TerminalWorkspaceStoreProtocol, AgentWorkspac
static let createSessionMeasurements = "v14_create_session_measurements"
static let addMeasurementProjectPath = "v15_add_measurement_project_path"
static let createContextProfiles = "v16_create_context_profiles"
static let createSessionLaunchContext = "v17_create_session_launch_context"
}

static let migrationIdentifiers = [
Expand All @@ -49,7 +50,8 @@ public actor SessionMetadataStore: TerminalWorkspaceStoreProtocol, AgentWorkspac
MigrationID.createPinnedSessionOrder,
MigrationID.createSessionMeasurements,
MigrationID.addMeasurementProjectPath,
MigrationID.createContextProfiles
MigrationID.createContextProfiles,
MigrationID.createSessionLaunchContext
]

private let dbQueue: DatabaseQueue
Expand Down Expand Up @@ -331,6 +333,21 @@ public actor SessionMetadataStore: TerminalWorkspaceStoreProtocol, AgentWorkspac
""")
}

// Curated launch context per session, so resume launches can re-pass the
// out-of-band context (system-prompt channel) the session started with —
// that text never enters conversation history, unlike the old
// first-message injection.
migrator.registerMigration(MigrationID.createSessionLaunchContext) { db in
try db.create(table: "session_launch_context") { t in
t.column("sessionId", .text).primaryKey()
t.column("provider", .text).notNull()
t.column("projectPath", .text).notNull()
t.column("contextText", .text).notNull()
t.column("createdAt", .datetime).notNull()
t.column("updatedAt", .datetime).notNull()
}
}

return migrator
}

Expand Down Expand Up @@ -1014,6 +1031,39 @@ extension SessionMetadataStore {
}
}

// MARK: - Session Launch Context

extension SessionMetadataStore {
/// Rows older than this are pruned on save: launch context is only re-read
/// when a session is resumed, and a month-old session losing its background
/// context is preferable to unbounded growth of up-to-48 KB rows.
public static let sessionLaunchContextMaxAge: TimeInterval = 30 * 24 * 60 * 60

/// Records the curated context a session was launched with (upsert), pruning
/// stale rows in the same transaction.
public func saveSessionLaunchContext(_ record: SessionLaunchContextRecord) throws {
try dbQueue.write { db in
let cutoff = Date().addingTimeInterval(-Self.sessionLaunchContextMaxAge)
try SessionLaunchContextRecord
.filter(Column("updatedAt") < cutoff)
.deleteAll(db)
var record = record
record.updatedAt = Date()
try record.save(db)
}
}

/// Synchronous read for command-line assembly at resume time, safe from
/// non-async contexts (mirrors `getAIConfigSync`).
public nonisolated func getSessionLaunchContextTextSync(for sessionId: String) -> String? {
try? dbQueue.read { db in
try SessionLaunchContextRecord
.fetchOne(db, key: sessionId)?
.contextText
}
}
}

extension SessionMetadataStore: ClaudeHookInstallStateStoreProtocol {
public func loadClaudeHookInstalledPaths() async throws -> Set<String> {
try await dbQueue.read { db in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public enum EmbeddedTerminalLaunchBuilder {
projectPath: String,
cliConfiguration: CLICommandConfiguration,
initialPrompt: String?,
launchContext: String? = nil,
dangerouslySkipPermissions: Bool,
permissionModePlan: Bool,
worktreeName: String?,
Expand All @@ -60,6 +61,7 @@ public enum EmbeddedTerminalLaunchBuilder {
projectPath: projectPath,
cliConfiguration: cliConfiguration,
initialPrompt: initialPrompt,
launchContext: launchContext,
dangerouslySkipPermissions: dangerouslySkipPermissions,
permissionModePlan: permissionModePlan,
worktreeName: worktreeName,
Expand All @@ -79,6 +81,7 @@ public enum EmbeddedTerminalLaunchBuilder {
projectPath: String,
cliConfiguration: CLICommandConfiguration,
initialPrompt: String?,
launchContext: String? = nil,
dangerouslySkipPermissions: Bool,
permissionModePlan: Bool,
worktreeName: String?,
Expand Down Expand Up @@ -146,7 +149,24 @@ public enum EmbeddedTerminalLaunchBuilder {
// Xcode projects get simulator-loop guidance at system-prompt level so
// agents verify through the same live app surface the user is watching.
// Tied to the bootstrap: guidance without the tools misleads agents.
let appendSystemPrompt = xcodeBuildMCPBootstrap == nil ? nil : SimulatorAgentGuidance.systemPrompt
let simulatorGuidance = xcodeBuildMCPBootstrap == nil ? nil : SimulatorAgentGuidance.systemPrompt
// Curated launch context rides the same out-of-band channel (Claude
// `--append-system-prompt`, Codex `-c developer_instructions=`) instead of
// the first user message. New sessions get it from the launch flow; resume
// has no in-memory copy, so the text persisted at session resolution is
// re-passed — it never entered conversation history.
let resolvedLaunchContext: String?
if isNewSession {
resolvedLaunchContext = launchContext
} else if let sessionId, !sessionId.isEmpty {
resolvedLaunchContext = metadataStore?.getSessionLaunchContextTextSync(for: sessionId)
} else {
resolvedLaunchContext = nil
}
let appendSystemPrompt = combinedAppendSystemPrompt(
simulatorGuidance: simulatorGuidance,
launchContext: resolvedLaunchContext
)
let args = cliConfiguration.argumentsForSession(
sessionId: sessionId,
prompt: initialPrompt,
Expand Down Expand Up @@ -264,6 +284,27 @@ public enum EmbeddedTerminalLaunchBuilder {
return environment
}

/// One line of provenance so the agent knows the block below is deliberate
/// user input, not something that leaked into its system prompt.
static let launchContextPreamble =
"The user attached the following curated context when launching this session. Use it as background for their upcoming tasks."

/// Single append-system-prompt value both providers receive. Guidance first
/// (short operational instructions), then the bulky context block.
static func combinedAppendSystemPrompt(
simulatorGuidance: String?,
launchContext: String?
) -> String? {
var parts: [String] = []
if let simulatorGuidance, !simulatorGuidance.isEmpty {
parts.append(simulatorGuidance)
}
if let context = launchContext?.trimmingCharacters(in: .whitespacesAndNewlines), !context.isEmpty {
parts.append(launchContextPreamble + "\n" + context)
}
return parts.isEmpty ? nil : parts.joined(separator: "\n\n")
}

static func shellEscape(_ value: String) -> String {
value.replacingOccurrences(of: "'", with: "'\\''")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public protocol EmbeddedTerminalSurface: AnyObject {
cliConfiguration: CLICommandConfiguration,
initialPrompt: String?,
initialInputText: String?,
launchContext: String?,
isDark: Bool,
dangerouslySkipPermissions: Bool,
permissionModePlan: Bool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public struct EmbeddedTerminalView: NSViewRepresentable {
let cliConfiguration: CLICommandConfiguration
let initialPrompt: String? // Optional: prompt to include with resume command
let initialInputText: String? // Optional: text to prefill terminal input without Enter
let launchContext: String? // Optional: curated context, delivered via the system-prompt channel
let viewModel: CLISessionsViewModel? // For shared terminal storage
let dangerouslySkipPermissions: Bool // One-shot flag for new sessions
let permissionModePlan: Bool // One-shot flag: start session in plan mode
Expand All @@ -110,6 +111,7 @@ public struct EmbeddedTerminalView: NSViewRepresentable {
cliConfiguration: CLICommandConfiguration,
initialPrompt: String? = nil,
initialInputText: String? = nil,
launchContext: String? = nil,
viewModel: CLISessionsViewModel? = nil,
dangerouslySkipPermissions: Bool = false,
permissionModePlan: Bool = false,
Expand All @@ -124,6 +126,7 @@ public struct EmbeddedTerminalView: NSViewRepresentable {
self.cliConfiguration = cliConfiguration
self.initialPrompt = initialPrompt
self.initialInputText = initialInputText
self.launchContext = launchContext
self.viewModel = viewModel
self.dangerouslySkipPermissions = dangerouslySkipPermissions
self.permissionModePlan = permissionModePlan
Expand Down Expand Up @@ -176,6 +179,7 @@ public struct EmbeddedTerminalView: NSViewRepresentable {
cliConfiguration: cliConfiguration,
initialPrompt: initialPrompt,
initialInputText: initialInputText,
launchContext: launchContext,
isDark: isDark,
dangerouslySkipPermissions: dangerouslySkipPermissions,
permissionModePlan: permissionModePlan,
Expand All @@ -197,6 +201,7 @@ public struct EmbeddedTerminalView: NSViewRepresentable {
cliConfiguration: cliConfiguration,
initialPrompt: initialPrompt,
initialInputText: initialInputText,
launchContext: launchContext,
isDark: isDark,
dangerouslySkipPermissions: dangerouslySkipPermissions,
permissionModePlan: permissionModePlan,
Expand Down Expand Up @@ -297,6 +302,7 @@ public class TerminalContainerView: NSView, ManagedLocalProcessTerminalViewDeleg
cliConfiguration: CLICommandConfiguration,
initialPrompt: String? = nil,
initialInputText: String? = nil,
launchContext: String? = nil,
isDark: Bool = true,
dangerouslySkipPermissions: Bool = false,
permissionModePlan: Bool = false,
Expand Down Expand Up @@ -328,6 +334,7 @@ public class TerminalContainerView: NSView, ManagedLocalProcessTerminalViewDeleg
projectPath: projectPath,
cliConfiguration: cliConfiguration,
initialPrompt: initialPrompt,
launchContext: launchContext,
dangerouslySkipPermissions: dangerouslySkipPermissions,
permissionModePlan: permissionModePlan,
worktreeName: worktreeName
Expand Down Expand Up @@ -740,6 +747,7 @@ public class TerminalContainerView: NSView, ManagedLocalProcessTerminalViewDeleg
projectPath: String,
cliConfiguration: CLICommandConfiguration,
initialPrompt: String? = nil,
launchContext: String? = nil,
dangerouslySkipPermissions: Bool = false,
permissionModePlan: Bool = false,
worktreeName: String? = nil
Expand All @@ -749,6 +757,7 @@ public class TerminalContainerView: NSView, ManagedLocalProcessTerminalViewDeleg
projectPath: projectPath,
cliConfiguration: cliConfiguration,
initialPrompt: initialPrompt,
launchContext: launchContext,
dangerouslySkipPermissions: dangerouslySkipPermissions,
permissionModePlan: permissionModePlan,
worktreeName: worktreeName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public struct MonitoringCardView: View {
let providerKind: SessionProviderKind
let initialPrompt: String?
let initialInputText: String?
let launchContext: String? // Curated context, delivered via the system-prompt channel
let terminalKey: String? // Key for terminal storage (session ID or "pending-{pendingId}")
let viewModel: CLISessionsViewModel?
let editorProjectPath: String
Expand Down Expand Up @@ -74,6 +75,7 @@ public struct MonitoringCardView: View {
providerKind: SessionProviderKind = .claude,
initialPrompt: String? = nil,
initialInputText: String? = nil,
launchContext: String? = nil,
terminalKey: String? = nil,
viewModel: CLISessionsViewModel? = nil,
contentMode: Binding<MonitoringCardContentMode> = .constant(.terminal),
Expand Down Expand Up @@ -113,6 +115,7 @@ public struct MonitoringCardView: View {
self.providerKind = providerKind
self.initialPrompt = initialPrompt
self.initialInputText = initialInputText
self.launchContext = launchContext
self.terminalKey = terminalKey
self.viewModel = viewModel
self._contentMode = contentMode
Expand Down Expand Up @@ -976,6 +979,7 @@ public struct MonitoringCardView: View {
cliConfiguration: liveCLIConfiguration,
initialPrompt: initialPrompt,
initialInputText: initialInputText,
launchContext: launchContext,
viewModel: viewModel,
dangerouslySkipPermissions: dangerouslySkipPermissions,
permissionModePlan: permissionModePlan,
Expand Down Expand Up @@ -1172,6 +1176,7 @@ private struct MonitoringCardTerminalContent: View {
let cliConfiguration: CLICommandConfiguration
let initialPrompt: String?
let initialInputText: String?
let launchContext: String?
let viewModel: CLISessionsViewModel?
let dangerouslySkipPermissions: Bool
let permissionModePlan: Bool
Expand All @@ -1188,6 +1193,7 @@ private struct MonitoringCardTerminalContent: View {
cliConfiguration: cliConfiguration,
initialPrompt: initialPrompt,
initialInputText: initialInputText,
launchContext: launchContext,
viewModel: viewModel,
dangerouslySkipPermissions: dangerouslySkipPermissions,
permissionModePlan: permissionModePlan,
Expand Down
Loading
Loading