Skip to content

Commit 8fbb34f

Browse files
committed
feat: add multi-session parallel support with isolated session state
Enable multiple Pilot sessions in the same project without interference. Each session gets isolated state under ~/.pilot/sessions/{PID}/ for continuation files, context cache, and stop guard. Includes Console dashboard integration, plan association API, statusline task display, and active sessions count indicator.
1 parent e674c01 commit 8fbb34f

42 files changed

Lines changed: 746 additions & 29 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,8 @@ Access the web-based Claude Pilot Console at **http://localhost:41777** to visua
195195

196196
- **Seamless Continuity** - Work on complex features across multiple sessions without losing progress
197197
- **Automatic Handoffs** - Context Monitor detects limits and continues seamlessly in new sessions
198+
- **Multi-Session Parallel** - Run multiple Pilot sessions in the same project without interference
198199
- **Persistent Memory** - Relevant observations automatically carry across all sessions
199-
- **Works Everywhere** - Both `/spec` workflow and Quick Mode benefit from session continuity
200200

201201
### 📋 Spec-Driven Development
202202

console/src/services/sqlite/SessionStore.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export class SessionStore {
4444
this.renameSessionIdColumns();
4545
this.repairSessionIdColumnRename();
4646
this.addFailedAtEpochColumn();
47+
this.ensureSessionPlansTable();
4748
}
4849

4950
/**
@@ -636,6 +637,31 @@ export class SessionStore {
636637
.run(20, new Date().toISOString());
637638
}
638639

640+
private ensureSessionPlansTable(): void {
641+
const applied = this.db.prepare("SELECT version FROM schema_versions WHERE version = ?").get(21) as
642+
| SchemaVersion
643+
| undefined;
644+
if (applied) return;
645+
646+
this.db.run(`
647+
CREATE TABLE IF NOT EXISTS session_plans (
648+
id INTEGER PRIMARY KEY AUTOINCREMENT,
649+
session_db_id INTEGER NOT NULL UNIQUE,
650+
plan_path TEXT NOT NULL,
651+
plan_status TEXT DEFAULT 'PENDING',
652+
created_at TEXT NOT NULL,
653+
updated_at TEXT NOT NULL,
654+
FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
655+
)
656+
`);
657+
658+
this.db
659+
.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)")
660+
.run(21, new Date().toISOString());
661+
662+
logger.debug("DB", "Created session_plans table for plan associations");
663+
}
664+
639665
/**
640666
* Update the memory session ID for a session
641667
* Called by SDKAgent when it captures the session ID from the first SDK message

console/src/services/sqlite/migrations.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,33 @@ export const migration009: Migration = {
522522
},
523523
};
524524

525+
/**
526+
* Migration 010 - Add session_plans table for session→plan associations
527+
* Tracks which session is working on which plan file
528+
*/
529+
export const migration010: Migration = {
530+
version: 10,
531+
up: (db: Database) => {
532+
db.run(`
533+
CREATE TABLE IF NOT EXISTS session_plans (
534+
id INTEGER PRIMARY KEY AUTOINCREMENT,
535+
session_db_id INTEGER NOT NULL UNIQUE,
536+
plan_path TEXT NOT NULL,
537+
plan_status TEXT DEFAULT 'PENDING',
538+
created_at TEXT NOT NULL,
539+
updated_at TEXT NOT NULL,
540+
FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
541+
)
542+
`);
543+
544+
console.log("✅ Created session_plans table for plan associations");
545+
},
546+
547+
down: (db: Database) => {
548+
db.run(`DROP TABLE IF EXISTS session_plans`);
549+
},
550+
};
551+
525552
/**
526553
* All migrations in order
527554
*/
@@ -535,4 +562,5 @@ export const migrations: Migration[] = [
535562
migration007,
536563
migration008,
537564
migration009,
565+
migration010,
538566
];
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Plan association store - session→plan CRUD operations.
3+
*/
4+
5+
import { Database } from "bun:sqlite";
6+
import type { SessionPlan, ActivePlan, DashboardSession } from "./types.js";
7+
8+
/** Ensure the session_plans table exists (migration010). */
9+
export function ensureSessionPlansTable(db: Database): void {
10+
db.run(`
11+
CREATE TABLE IF NOT EXISTS session_plans (
12+
id INTEGER PRIMARY KEY AUTOINCREMENT,
13+
session_db_id INTEGER NOT NULL UNIQUE,
14+
plan_path TEXT NOT NULL,
15+
plan_status TEXT DEFAULT 'PENDING',
16+
created_at TEXT NOT NULL,
17+
updated_at TEXT NOT NULL,
18+
FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
19+
)
20+
`);
21+
}
22+
23+
/** Associate a plan with a session (upsert). */
24+
export function associatePlan(
25+
db: Database,
26+
sessionDbId: number,
27+
planPath: string,
28+
status: string,
29+
): SessionPlan | null {
30+
const now = new Date().toISOString();
31+
32+
db.prepare(
33+
`INSERT INTO session_plans (session_db_id, plan_path, plan_status, created_at, updated_at)
34+
VALUES (?, ?, ?, ?, ?)
35+
ON CONFLICT(session_db_id)
36+
DO UPDATE SET plan_path = excluded.plan_path,
37+
plan_status = excluded.plan_status,
38+
updated_at = excluded.updated_at`,
39+
).run(sessionDbId, planPath, status, now, now);
40+
41+
return getPlanForSession(db, sessionDbId);
42+
}
43+
44+
/** Get plan for session by database ID. */
45+
export function getPlanForSession(db: Database, sessionDbId: number): SessionPlan | null {
46+
return (
47+
db
48+
.prepare("SELECT * FROM session_plans WHERE session_db_id = ?")
49+
.get(sessionDbId) as SessionPlan | null
50+
);
51+
}
52+
53+
/** Get plan by content session ID (joins sdk_sessions). */
54+
export function getPlanByContentSessionId(
55+
db: Database,
56+
contentSessionId: string,
57+
): SessionPlan | null {
58+
return (
59+
db
60+
.prepare(
61+
`SELECT sp.* FROM session_plans sp
62+
JOIN sdk_sessions ss ON sp.session_db_id = ss.id
63+
WHERE ss.content_session_id = ?`,
64+
)
65+
.get(contentSessionId) as SessionPlan | null
66+
);
67+
}
68+
69+
/** Get all active plan associations. */
70+
export function getAllActivePlans(db: Database): ActivePlan[] {
71+
return db
72+
.prepare(
73+
`SELECT sp.session_db_id, ss.content_session_id, sp.plan_path, sp.plan_status, ss.project
74+
FROM session_plans sp
75+
JOIN sdk_sessions ss ON sp.session_db_id = ss.id`,
76+
)
77+
.all() as ActivePlan[];
78+
}
79+
80+
/** Update plan status for a session. */
81+
export function updatePlanStatus(db: Database, sessionDbId: number, status: string): void {
82+
const now = new Date().toISOString();
83+
db.prepare("UPDATE session_plans SET plan_status = ?, updated_at = ? WHERE session_db_id = ?").run(
84+
status,
85+
now,
86+
sessionDbId,
87+
);
88+
}
89+
90+
/** Clear plan association for a session. */
91+
export function clearPlanAssociation(db: Database, sessionDbId: number): void {
92+
db.prepare("DELETE FROM session_plans WHERE session_db_id = ?").run(sessionDbId);
93+
}
94+
95+
/** Get all active sessions with optional plan associations for dashboard. */
96+
export function getDashboardSessions(db: Database): DashboardSession[] {
97+
return db
98+
.prepare(
99+
`SELECT ss.id AS session_db_id, ss.content_session_id, ss.project,
100+
ss.status, ss.started_at, sp.plan_path, sp.plan_status
101+
FROM sdk_sessions ss
102+
LEFT JOIN session_plans sp ON sp.session_db_id = ss.id
103+
WHERE ss.status = 'active'
104+
ORDER BY ss.started_at_epoch DESC`,
105+
)
106+
.all() as DashboardSession[];
107+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Type definitions for session→plan association operations.
3+
*/
4+
5+
/** Row returned from session_plans table. */
6+
export interface SessionPlan {
7+
id: number;
8+
session_db_id: number;
9+
plan_path: string;
10+
plan_status: string;
11+
created_at: string;
12+
updated_at: string;
13+
}
14+
15+
/** Active plan with session context. */
16+
export interface ActivePlan {
17+
session_db_id: number;
18+
content_session_id: string;
19+
plan_path: string;
20+
plan_status: string;
21+
project: string;
22+
}
23+
24+
/** Dashboard session row with optional plan association. */
25+
export interface DashboardSession {
26+
session_db_id: number;
27+
content_session_id: string;
28+
project: string;
29+
status: string;
30+
started_at: string;
31+
plan_path: string | null;
32+
plan_status: string | null;
33+
}

console/src/services/worker-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ export class WorkerService {
215215
this.server.registerRoutes(new MemoryRoutes(this.dbManager, "pilot-memory"));
216216
this.server.registerRoutes(new BackupRoutes(this.dbManager));
217217
this.server.registerRoutes(new RetentionRoutes(this.dbManager));
218-
this.server.registerRoutes(new PlanRoutes());
218+
this.server.registerRoutes(new PlanRoutes(this.dbManager, this.sseBroadcaster));
219219

220220
this.metricsService = new MetricsService(this.dbManager, this.sessionManager, this.startTime);
221221
this.server.registerRoutes(new MetricsRoutes(this.metricsService));

0 commit comments

Comments
 (0)