@grackle-ai/server 0.14.9 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/db.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAMtC,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAatC,+EAA+E;AAC/E,wBAAgB,YAAY,IAAI,IAAI,CAmJnC;AAKD,yDAAyD;AACzD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,QAAA,MAAM,EAAE,EAAE,qBAAqB,CAAC,OAAO,MAAM,CAAC,GAAG;IAAE,OAAO,EAAE,YAAY,CAAC,OAAO,QAAQ,CAAC,CAAA;CAAgC,CAAC;AAE1H,eAAe,EAAE,CAAC"}
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAMtC,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAatC,+EAA+E;AAC/E,wBAAgB,YAAY,IAAI,IAAI,CAgMnC;AAKD,yDAAyD;AACzD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,QAAA,MAAM,EAAE,EAAE,qBAAqB,CAAC,OAAO,MAAM,CAAC,GAAG;IAC/C,OAAO,EAAE,YAAY,CAAC,OAAO,QAAQ,CAAC,CAAC;CACV,CAAC;AAEhC,eAAe,EAAE,CAAC"}
package/dist/db.js CHANGED
@@ -81,7 +81,9 @@ export function initDatabase() {
81
81
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
82
82
  sort_order INTEGER NOT NULL DEFAULT 0,
83
83
  parent_task_id TEXT NOT NULL DEFAULT '',
84
- depth INTEGER NOT NULL DEFAULT 0
84
+ depth INTEGER NOT NULL DEFAULT 0,
85
+ can_decompose INTEGER NOT NULL DEFAULT 0,
86
+ persona_id TEXT NOT NULL DEFAULT ''
85
87
  );
86
88
 
87
89
  CREATE TABLE IF NOT EXISTS findings (
@@ -96,18 +98,36 @@ export function initDatabase() {
96
98
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
97
99
  );
98
100
 
101
+ CREATE TABLE IF NOT EXISTS personas (
102
+ id TEXT PRIMARY KEY,
103
+ name TEXT NOT NULL UNIQUE,
104
+ description TEXT NOT NULL DEFAULT '',
105
+ system_prompt TEXT NOT NULL,
106
+ tool_config TEXT NOT NULL DEFAULT '{}',
107
+ runtime TEXT NOT NULL DEFAULT '',
108
+ model TEXT NOT NULL DEFAULT '',
109
+ max_turns INTEGER NOT NULL DEFAULT 0,
110
+ mcp_servers TEXT NOT NULL DEFAULT '[]',
111
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
112
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
113
+ );
114
+
99
115
  CREATE INDEX IF NOT EXISTS idx_findings_project ON findings(project_id);
100
116
  `);
101
117
  // Migration: add powerline_token column if missing (older databases)
102
118
  try {
103
119
  sqlite.exec("ALTER TABLE environments ADD COLUMN powerline_token TEXT NOT NULL DEFAULT ''");
104
120
  }
105
- catch { /* column already exists */ }
121
+ catch {
122
+ /* column already exists */
123
+ }
106
124
  // Migration: rename sidecar_token → powerline_token (from older databases)
107
125
  try {
108
126
  sqlite.exec("ALTER TABLE environments RENAME COLUMN sidecar_token TO powerline_token");
109
127
  }
110
- catch { /* column already renamed or doesn't exist */ }
128
+ catch {
129
+ /* column already renamed or doesn't exist */
130
+ }
111
131
  // Migration: backfill NULLs in stage-2 tables from older schemas that lacked NOT NULL
112
132
  sqlite.exec(`
113
133
  UPDATE projects SET description = '' WHERE description IS NULL;
@@ -138,11 +158,15 @@ export function initDatabase() {
138
158
  try {
139
159
  sqlite.exec("ALTER TABLE tasks ADD COLUMN parent_task_id TEXT NOT NULL DEFAULT ''");
140
160
  }
141
- catch { /* column already exists */ }
161
+ catch {
162
+ /* column already exists */
163
+ }
142
164
  try {
143
165
  sqlite.exec("ALTER TABLE tasks ADD COLUMN depth INTEGER NOT NULL DEFAULT 0");
144
166
  }
145
- catch { /* column already exists */ }
167
+ catch {
168
+ /* column already exists */
169
+ }
146
170
  // Migration: add can_decompose column if missing (older databases)
147
171
  try {
148
172
  sqlite.exec("ALTER TABLE tasks ADD COLUMN can_decompose INTEGER NOT NULL DEFAULT 0");
@@ -158,7 +182,16 @@ export function initDatabase() {
158
182
  )
159
183
  `);
160
184
  }
161
- catch { /* column already exists */ }
185
+ catch {
186
+ /* column already exists */
187
+ }
188
+ // Migration: add persona_id column to tasks if missing
189
+ try {
190
+ sqlite.exec("ALTER TABLE tasks ADD COLUMN persona_id TEXT NOT NULL DEFAULT ''");
191
+ }
192
+ catch {
193
+ /* column already exists */
194
+ }
162
195
  }
163
196
  // Run init immediately for backwards compatibility — stores import db at module load
164
197
  initDatabase();
package/dist/db.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AACtC,OAAO,EAAE,OAAO,EAAE,MAAM,4BAA4B,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAEtC,SAAS,CAAC,WAAW,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;AAE5C,MAAM,MAAM,GAAW,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;AACtD,MAAM,MAAM,GAAkC,IAAI,QAAQ,CAAC,MAAM,CAAC,CAAC;AAEnE,yDAAyD;AACzD,MAAM,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC;AAEpC,4DAA4D;AAC5D,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;AAEnC,+EAA+E;AAC/E,MAAM,UAAU,YAAY;IAC1B,wDAAwD;IACxD,MAAM,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkFX,CAAC,CAAC;IAEH,qEAAqE;IACrE,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CAAC,8EAA8E,CAAC,CAAC;IAC9F,CAAC;IAAC,MAAM,CAAC,CAAC,2BAA2B,CAAC,CAAC;IAEvC,2EAA2E;IAC3E,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CAAC,yEAAyE,CAAC,CAAC;IACzF,CAAC;IAAC,MAAM,CAAC,CAAC,6CAA6C,CAAC,CAAC;IAEzD,sFAAsF;IACtF,MAAM,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;GAwBX,CAAC,CAAC;IAEH,+EAA+E;IAC/E,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CAAC,sEAAsE,CAAC,CAAC;IACtF,CAAC;IAAC,MAAM,CAAC,CAAC,2BAA2B,CAAC,CAAC;IACvC,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CAAC,+DAA+D,CAAC,CAAC;IAC/E,CAAC;IAAC,MAAM,CAAC,CAAC,2BAA2B,CAAC,CAAC;IAEvC,mEAAmE;IACnE,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CAAC,uEAAuE,CAAC,CAAC;QAErF,6EAA6E;QAC7E,MAAM,CAAC,IAAI,CAAC;;;;;;;;;KASX,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC,CAAC,2BAA2B,CAAC,CAAC;AACzC,CAAC;AAED,qFAAqF;AACrF,YAAY,EAAE,CAAC;AAIf,MAAM,EAAE,GAAsF,OAAO,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;AAE1H,eAAe,EAAE,CAAC"}
1
+ {"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AACtC,OAAO,EAAE,OAAO,EAAE,MAAM,4BAA4B,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAEtC,SAAS,CAAC,WAAW,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;AAE5C,MAAM,MAAM,GAAW,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;AACtD,MAAM,MAAM,GAAkC,IAAI,QAAQ,CAAC,MAAM,CAAC,CAAC;AAEnE,yDAAyD;AACzD,MAAM,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC;AAEpC,4DAA4D;AAC5D,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;AAEnC,+EAA+E;AAC/E,MAAM,UAAU,YAAY;IAC1B,wDAAwD;IACxD,MAAM,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkGX,CAAC,CAAC;IAEH,qEAAqE;IACrE,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CACT,8EAA8E,CAC/E,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,2BAA2B;IAC7B,CAAC;IAED,2EAA2E;IAC3E,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CACT,yEAAyE,CAC1E,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,6CAA6C;IAC/C,CAAC;IAED,sFAAsF;IACtF,MAAM,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;GAwBX,CAAC,CAAC;IAEH,+EAA+E;IAC/E,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CACT,sEAAsE,CACvE,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,2BAA2B;IAC7B,CAAC;IACD,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CACT,+DAA+D,CAChE,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,2BAA2B;IAC7B,CAAC;IAED,mEAAmE;IACnE,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CACT,uEAAuE,CACxE,CAAC;QAEF,6EAA6E;QAC7E,MAAM,CAAC,IAAI,CAAC;;;;;;;;;KASX,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACP,2BAA2B;IAC7B,CAAC;IAED,uDAAuD;IACvD,IAAI,CAAC;QACH,MAAM,CAAC,IAAI,CACT,kEAAkE,CACnE,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,2BAA2B;IAC7B,CAAC;AACH,CAAC;AAED,qFAAqF;AACrF,YAAY,EAAE,CAAC;AAIf,MAAM,EAAE,GAEJ,OAAO,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;AAEhC,eAAe,EAAE,CAAC"}
@@ -1,4 +1,11 @@
1
1
  import type { ConnectRouter } from "@connectrpc/connect";
2
+ /** Build a JSON string of MCP server configs for the PowerLine SpawnRequest. */
3
+ export declare function buildMcpServersJson(mcpServers: {
4
+ name: string;
5
+ command: string;
6
+ args?: string[];
7
+ tools?: string[];
8
+ }[]): string;
2
9
  /** Register all Grackle gRPC service handlers on the given ConnectRPC router. */
3
10
  export declare function registerGrackleRoutes(router: ConnectRouter): void;
4
11
  //# sourceMappingURL=grpc-service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"grpc-service.d.ts","sourceRoot":"","sources":["../src/grpc-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAuGzD,iFAAiF;AACjF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,aAAa,GAAG,IAAI,CAukBjE"}
1
+ {"version":3,"file":"grpc-service.d.ts","sourceRoot":"","sources":["../src/grpc-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAsMzD,gFAAgF;AAChF,wBAAgB,mBAAmB,CACjC,UAAU,EAAE;IACV,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB,EAAE,GACF,MAAM,CAUR;AAED,iFAAiF;AACjF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,aAAa,GAAG,IAAI,CAy1BjE"}
@@ -10,6 +10,7 @@ import * as tokenBroker from "./token-broker.js";
10
10
  import * as projectStore from "./project-store.js";
11
11
  import * as taskStore from "./task-store.js";
12
12
  import * as findingStore from "./finding-store.js";
13
+ import * as personaStore from "./persona-store.js";
13
14
  import { broadcast } from "./ws-broadcast.js";
14
15
  import { processEventStream } from "./event-processor.js";
15
16
  import { join } from "node:path";
@@ -82,12 +83,87 @@ function taskRowToProto(row, childIds) {
82
83
  sortOrder: row.sortOrder,
83
84
  parentTaskId: row.parentTaskId,
84
85
  depth: row.depth,
85
- childTaskIds: childIds ?? taskStore.getChildren(row.id).map(c => c.id),
86
+ childTaskIds: childIds ?? taskStore.getChildren(row.id).map((c) => c.id),
86
87
  canDecompose: row.canDecompose,
88
+ personaId: row.personaId,
87
89
  });
88
90
  }
89
91
  function findingRowToProto(row) {
90
- return create(grackle.FindingSchema, { ...row, tags: safeParseJsonArray(row.tags) });
92
+ return create(grackle.FindingSchema, {
93
+ ...row,
94
+ tags: safeParseJsonArray(row.tags),
95
+ });
96
+ }
97
+ /** Safely parse a JSON string, returning the fallback value on failure. */
98
+ function safeParseJson(value, fallback) {
99
+ if (!value) {
100
+ return fallback;
101
+ }
102
+ try {
103
+ return JSON.parse(value);
104
+ }
105
+ catch {
106
+ return fallback;
107
+ }
108
+ }
109
+ /** Convert a persona database row to a Persona proto message. */
110
+ function personaRowToProto(row) {
111
+ const toolConfig = safeParseJson(row.toolConfig, {});
112
+ const mcpServers = safeParseJson(row.mcpServers, []);
113
+ return create(grackle.PersonaSchema, {
114
+ id: row.id,
115
+ name: row.name,
116
+ description: row.description,
117
+ systemPrompt: row.systemPrompt,
118
+ toolConfig: create(grackle.ToolConfigSchema, {
119
+ allowedTools: Array.isArray(toolConfig.allowedTools)
120
+ ? toolConfig.allowedTools.filter((t) => typeof t === "string")
121
+ : [],
122
+ disallowedTools: Array.isArray(toolConfig.disallowedTools)
123
+ ? toolConfig.disallowedTools.filter((t) => typeof t === "string")
124
+ : [],
125
+ }),
126
+ runtime: row.runtime,
127
+ model: row.model,
128
+ maxTurns: row.maxTurns,
129
+ mcpServers: mcpServers
130
+ .filter((s) => typeof s === "object" &&
131
+ s !== null &&
132
+ typeof s.name === "string" &&
133
+ typeof s.command === "string")
134
+ .map((s) => create(grackle.McpServerConfigSchema, {
135
+ name: s.name,
136
+ command: s.command,
137
+ args: Array.isArray(s.args)
138
+ ? s.args.filter((a) => typeof a === "string")
139
+ : [],
140
+ tools: Array.isArray(s.tools)
141
+ ? s.tools.filter((t) => typeof t === "string")
142
+ : [],
143
+ })),
144
+ createdAt: row.createdAt,
145
+ updatedAt: row.updatedAt,
146
+ });
147
+ }
148
+ /** Convert persona MCP server configs to a JSON string for the PowerLine SpawnRequest. */
149
+ function personaMcpServersToJson(row) {
150
+ const mcpServers = JSON.parse(row.mcpServers || "[]");
151
+ if (mcpServers.length === 0) {
152
+ return "";
153
+ }
154
+ return buildMcpServersJson(mcpServers);
155
+ }
156
+ /** Build a JSON string of MCP server configs for the PowerLine SpawnRequest. */
157
+ export function buildMcpServersJson(mcpServers) {
158
+ const obj = {};
159
+ for (const s of mcpServers) {
160
+ obj[s.name] = {
161
+ command: s.command,
162
+ args: s.args || [],
163
+ ...(s.tools && s.tools.length > 0 ? { tools: s.tools } : {}),
164
+ };
165
+ }
166
+ return JSON.stringify(obj);
91
167
  }
92
168
  /** Register all Grackle gRPC service handlers on the given ConnectRPC router. */
93
169
  export function registerGrackleRoutes(router) {
@@ -114,14 +190,19 @@ export function registerGrackleRoutes(router) {
114
190
  try {
115
191
  await adapter.disconnect(req.id);
116
192
  }
117
- catch { /* best-effort */ }
193
+ catch {
194
+ /* best-effort */
195
+ }
118
196
  }
119
197
  }
120
198
  adapterManager.removeConnection(req.id);
121
199
  // Delete sessions referencing this environment (FK constraint)
122
200
  sessionStore.deleteByEnvironment(req.id);
123
201
  envRegistry.removeEnvironment(req.id);
124
- broadcast({ type: "environment_removed", payload: { environmentId: req.id } });
202
+ broadcast({
203
+ type: "environment_removed",
204
+ payload: { environmentId: req.id },
205
+ });
125
206
  return create(grackle.EmptySchema, {});
126
207
  },
127
208
  async *provisionEnvironment(req) {
@@ -210,22 +291,42 @@ export function registerGrackleRoutes(router) {
210
291
  if (!conn) {
211
292
  throw new Error(`Environment ${req.environmentId} not connected`);
212
293
  }
294
+ // Resolve persona if specified
295
+ const persona = req.personaId
296
+ ? personaStore.getPersona(req.personaId)
297
+ : undefined;
298
+ if (req.personaId && !persona) {
299
+ throw new Error(`Persona not found: ${req.personaId}`);
300
+ }
213
301
  const sessionId = uuid();
214
- const runtime = req.runtime || env.defaultRuntime;
215
- const model = req.model || process.env.GRACKLE_DEFAULT_MODEL || DEFAULT_MODEL;
302
+ const runtime = req.runtime || persona?.runtime || env.defaultRuntime;
303
+ const model = req.model ||
304
+ persona?.model ||
305
+ process.env.GRACKLE_DEFAULT_MODEL ||
306
+ DEFAULT_MODEL;
216
307
  const logPath = join(grackleHome, LOGS_DIR, sessionId);
308
+ let systemContext = req.systemContext || "";
309
+ if (persona) {
310
+ systemContext =
311
+ persona.systemPrompt + (systemContext ? "\n\n" + systemContext : "");
312
+ }
217
313
  sessionStore.createSession(sessionId, req.environmentId, runtime, req.prompt, model, logPath);
314
+ const mcpServersJson = persona ? personaMcpServersToJson(persona) : "";
218
315
  const powerlineReq = create(powerline.SpawnRequestSchema, {
219
316
  sessionId,
220
317
  runtime,
221
318
  prompt: req.prompt,
222
319
  model,
223
- maxTurns: 0,
320
+ maxTurns: persona?.maxTurns || 0,
224
321
  branch: req.branch || "",
225
322
  worktreeBasePath: req.branch ? "/workspace" : "",
226
- systemContext: req.systemContext || "",
323
+ systemContext,
324
+ mcpServersJson,
325
+ });
326
+ processEventStream(conn.client.spawn(powerlineReq), {
327
+ sessionId,
328
+ logPath,
227
329
  });
228
- processEventStream(conn.client.spawn(powerlineReq), { sessionId, logPath });
229
330
  const row = sessionStore.getSession(sessionId);
230
331
  return sessionRowToProto(row);
231
332
  },
@@ -244,7 +345,10 @@ export function registerGrackleRoutes(router) {
244
345
  runtime: session.runtime,
245
346
  });
246
347
  const logPath = session.logPath || join(grackleHome, LOGS_DIR, session.id);
247
- processEventStream(conn.client.resume(powerlineReq), { sessionId: session.id, logPath });
348
+ processEventStream(conn.client.resume(powerlineReq), {
349
+ sessionId: session.id,
350
+ logPath,
351
+ });
248
352
  const row = sessionStore.getSession(session.id);
249
353
  return sessionRowToProto(row);
250
354
  },
@@ -374,7 +478,7 @@ export function registerGrackleRoutes(router) {
374
478
  const rows = taskStore.listTasks(req.id);
375
479
  const childIdsMap = taskStore.buildChildIdsMap(rows);
376
480
  return create(grackle.TaskListSchema, {
377
- tasks: rows.map(r => taskRowToProto(r, childIdsMap.get(r.id) ?? [])),
481
+ tasks: rows.map((r) => taskRowToProto(r, childIdsMap.get(r.id) ?? [])),
378
482
  });
379
483
  },
380
484
  async createTask(req) {
@@ -395,9 +499,12 @@ export function registerGrackleRoutes(router) {
395
499
  }
396
500
  const id = uuid().slice(0, 8);
397
501
  const environmentId = req.environmentId || project.defaultEnvironmentId;
398
- taskStore.createTask(id, req.projectId, req.title, req.description, environmentId, [...req.dependsOn], slugify(project.name), req.parentTaskId, req.canDecompose);
502
+ taskStore.createTask(id, req.projectId, req.title, req.description, environmentId, [...req.dependsOn], slugify(project.name), req.parentTaskId, req.canDecompose, req.personaId);
399
503
  const row = taskStore.getTask(id);
400
- broadcast({ type: "task_created", payload: { task: row ? { ...row } : null } });
504
+ broadcast({
505
+ type: "task_created",
506
+ payload: { task: row ? { ...row } : null },
507
+ });
401
508
  return taskRowToProto(row);
402
509
  },
403
510
  async getTask(req) {
@@ -418,7 +525,9 @@ export function registerGrackleRoutes(router) {
418
525
  }
419
526
  reqStatus = converted;
420
527
  }
421
- taskStore.updateTask(req.id, req.title !== "" ? req.title : existing.title, req.description !== "" ? req.description : existing.description, reqStatus, req.environmentId !== "" ? req.environmentId : existing.environmentId, req.dependsOn.length > 0 ? [...req.dependsOn] : safeParseJsonArray(existing.dependsOn), req.reviewNotes !== "" ? req.reviewNotes : existing.reviewNotes);
528
+ taskStore.updateTask(req.id, req.title !== "" ? req.title : existing.title, req.description !== "" ? req.description : existing.description, reqStatus, req.environmentId !== "" ? req.environmentId : existing.environmentId, req.dependsOn.length > 0
529
+ ? [...req.dependsOn]
530
+ : safeParseJsonArray(existing.dependsOn), req.reviewNotes !== "" ? req.reviewNotes : existing.reviewNotes);
422
531
  const row = taskStore.getTask(req.id);
423
532
  return taskRowToProto(row);
424
533
  },
@@ -441,27 +550,50 @@ export function registerGrackleRoutes(router) {
441
550
  const conn = adapterManager.getConnection(environmentId);
442
551
  if (!conn)
443
552
  throw new Error(`Environment ${environmentId} not connected`);
553
+ // Resolve persona (StartTaskRequest override > task's stored persona)
554
+ const personaId = req.personaId || task.personaId;
555
+ const persona = personaId
556
+ ? personaStore.getPersona(personaId)
557
+ : undefined;
558
+ if (personaId && !persona) {
559
+ throw new Error(`Persona not found: ${personaId}`);
560
+ }
444
561
  const env = envRegistry.getEnvironment(environmentId);
445
562
  const sessionId = uuid();
446
- const runtime = req.runtime || env?.defaultRuntime || DEFAULT_RUNTIME;
447
- const model = req.model || process.env.GRACKLE_DEFAULT_MODEL || DEFAULT_MODEL;
563
+ const runtime = req.runtime ||
564
+ persona?.runtime ||
565
+ env?.defaultRuntime ||
566
+ DEFAULT_RUNTIME;
567
+ const model = req.model ||
568
+ persona?.model ||
569
+ process.env.GRACKLE_DEFAULT_MODEL ||
570
+ DEFAULT_MODEL;
571
+ const maxTurns = persona?.maxTurns || 0;
448
572
  const logPath = join(grackleHome, LOGS_DIR, sessionId);
449
- const systemContext = buildTaskSystemContext(task.title, task.description, task.reviewNotes, task.canDecompose);
573
+ let systemContext = buildTaskSystemContext(task.title, task.description, task.reviewNotes, task.canDecompose);
574
+ if (persona) {
575
+ systemContext = persona.systemPrompt + "\n\n" + systemContext;
576
+ }
450
577
  sessionStore.createSession(sessionId, environmentId, runtime, task.title, model, logPath);
451
578
  taskStore.setTaskSession(task.id, sessionId);
452
579
  taskStore.markTaskStarted(task.id);
453
- broadcast({ type: "task_started", payload: { taskId: task.id, sessionId, projectId: task.projectId } });
580
+ broadcast({
581
+ type: "task_started",
582
+ payload: { taskId: task.id, sessionId, projectId: task.projectId },
583
+ });
584
+ const mcpServersJson = persona ? personaMcpServersToJson(persona) : "";
454
585
  const powerlineReq = create(powerline.SpawnRequestSchema, {
455
586
  sessionId,
456
587
  runtime,
457
588
  prompt: task.title,
458
589
  model,
459
- maxTurns: 0,
590
+ maxTurns,
460
591
  branch: task.branch,
461
592
  worktreeBasePath: task.branch ? "/workspace" : "",
462
593
  systemContext,
463
594
  projectId: task.projectId,
464
595
  taskId: task.id,
596
+ mcpServersJson,
465
597
  });
466
598
  processEventStream(conn.client.spawn(powerlineReq), {
467
599
  sessionId,
@@ -479,7 +611,10 @@ export function registerGrackleRoutes(router) {
479
611
  else if (sess?.status === "failed") {
480
612
  taskStore.markTaskCompleted(task.id, "failed");
481
613
  }
482
- broadcast({ type: "task_updated", payload: { taskId: task.id, projectId: task.projectId } });
614
+ broadcast({
615
+ type: "task_updated",
616
+ payload: { taskId: task.id, projectId: task.projectId },
617
+ });
483
618
  }
484
619
  },
485
620
  });
@@ -498,11 +633,18 @@ export function registerGrackleRoutes(router) {
498
633
  sessionId: "",
499
634
  type: grackle.EventType.SYSTEM,
500
635
  timestamp: new Date().toISOString(),
501
- content: JSON.stringify({ type: "task_unblocked", taskId: t.id, title: t.title }),
636
+ content: JSON.stringify({
637
+ type: "task_unblocked",
638
+ taskId: t.id,
639
+ title: t.title,
640
+ }),
502
641
  raw: "",
503
642
  }));
504
643
  }
505
- broadcast({ type: "task_approved", payload: { taskId: task.id, projectId: task.projectId } });
644
+ broadcast({
645
+ type: "task_approved",
646
+ payload: { taskId: task.id, projectId: task.projectId },
647
+ });
506
648
  const row = taskStore.getTask(task.id);
507
649
  return taskRowToProto(row);
508
650
  },
@@ -511,7 +653,10 @@ export function registerGrackleRoutes(router) {
511
653
  if (!task)
512
654
  throw new Error(`Task not found: ${req.id}`);
513
655
  taskStore.updateTask(task.id, task.title, task.description, "assigned", task.environmentId, safeParseJsonArray(task.dependsOn), req.reviewNotes || "");
514
- broadcast({ type: "task_rejected", payload: { taskId: task.id, projectId: task.projectId } });
656
+ broadcast({
657
+ type: "task_rejected",
658
+ payload: { taskId: task.id, projectId: task.projectId },
659
+ });
515
660
  const row = taskStore.getTask(task.id);
516
661
  return taskRowToProto(row);
517
662
  },
@@ -522,14 +667,107 @@ export function registerGrackleRoutes(router) {
522
667
  throw new Error("Cannot delete task with children. Delete children first.");
523
668
  }
524
669
  taskStore.deleteTask(req.id);
525
- broadcast({ type: "task_deleted", payload: { taskId: req.id, projectId: task?.projectId } });
670
+ broadcast({
671
+ type: "task_deleted",
672
+ payload: { taskId: req.id, projectId: task?.projectId },
673
+ });
674
+ return create(grackle.EmptySchema, {});
675
+ },
676
+ // ─── Personas ───────────────────────────────────────────────
677
+ async listPersonas() {
678
+ const rows = personaStore.listPersonas();
679
+ return create(grackle.PersonaListSchema, {
680
+ personas: rows.map(personaRowToProto),
681
+ });
682
+ },
683
+ async createPersona(req) {
684
+ if (!req.name)
685
+ throw new Error("Persona name is required");
686
+ if (!req.systemPrompt)
687
+ throw new Error("Persona system_prompt is required");
688
+ // Enforce unique ID and unique name
689
+ let id = slugify(req.name) || uuid().slice(0, 8);
690
+ if (personaStore.getPersona(id)) {
691
+ id = `${id}-${uuid().slice(0, 4)}`;
692
+ }
693
+ if (personaStore.getPersonaByName(req.name)) {
694
+ throw new Error(`Persona with name "${req.name}" already exists`);
695
+ }
696
+ const toolConfigJson = JSON.stringify({
697
+ allowedTools: [...(req.toolConfig?.allowedTools || [])],
698
+ disallowedTools: [...(req.toolConfig?.disallowedTools || [])],
699
+ });
700
+ const mcpServersJson = JSON.stringify(req.mcpServers.map((s) => ({
701
+ name: s.name,
702
+ command: s.command,
703
+ args: [...s.args],
704
+ tools: [...s.tools],
705
+ })));
706
+ personaStore.createPersona(id, req.name, req.description, req.systemPrompt, toolConfigJson, req.runtime, req.model, req.maxTurns, mcpServersJson);
707
+ broadcast({ type: "persona_created", payload: { personaId: id } });
708
+ const row = personaStore.getPersona(id);
709
+ return personaRowToProto(row);
710
+ },
711
+ async getPersona(req) {
712
+ const row = personaStore.getPersona(req.id);
713
+ if (!row)
714
+ throw new Error(`Persona not found: ${req.id}`);
715
+ return personaRowToProto(row);
716
+ },
717
+ async updatePersona(req) {
718
+ const existing = personaStore.getPersona(req.id);
719
+ if (!existing)
720
+ throw new Error(`Persona not found: ${req.id}`);
721
+ // Only update toolConfig/mcpServers if the request provides non-empty values;
722
+ // otherwise keep the existing stored value.
723
+ const hasNewToolConfig = !!req.toolConfig &&
724
+ ((req.toolConfig.allowedTools &&
725
+ req.toolConfig.allowedTools.length > 0) ||
726
+ (req.toolConfig.disallowedTools &&
727
+ req.toolConfig.disallowedTools.length > 0));
728
+ const toolConfigJson = hasNewToolConfig
729
+ ? JSON.stringify({
730
+ allowedTools: [...(req.toolConfig?.allowedTools || [])],
731
+ disallowedTools: [...(req.toolConfig?.disallowedTools || [])],
732
+ })
733
+ : existing.toolConfig;
734
+ const hasNewMcpServers = Array.isArray(req.mcpServers) && req.mcpServers.length > 0;
735
+ const mcpServersJson = hasNewMcpServers
736
+ ? JSON.stringify(req.mcpServers.map((s) => ({
737
+ name: s.name,
738
+ command: s.command,
739
+ args: [...s.args],
740
+ tools: [...s.tools],
741
+ })))
742
+ : existing.mcpServers;
743
+ // Treat empty string / 0 as "not set" and keep existing value
744
+ const name = req.name || existing.name;
745
+ if (name !== existing.name && personaStore.getPersonaByName(name)) {
746
+ throw new Error(`Persona with name "${name}" already exists`);
747
+ }
748
+ const description = req.description || existing.description;
749
+ const systemPrompt = req.systemPrompt || existing.systemPrompt;
750
+ const runtime = req.runtime || existing.runtime;
751
+ const model = req.model || existing.model;
752
+ const maxTurns = req.maxTurns === 0 ? existing.maxTurns : req.maxTurns;
753
+ personaStore.updatePersona(req.id, name, description, systemPrompt, toolConfigJson, runtime, model, maxTurns, mcpServersJson);
754
+ broadcast({ type: "persona_updated", payload: { personaId: req.id } });
755
+ const row = personaStore.getPersona(req.id);
756
+ return personaRowToProto(row);
757
+ },
758
+ async deletePersona(req) {
759
+ personaStore.deletePersona(req.id);
760
+ broadcast({ type: "persona_deleted", payload: { personaId: req.id } });
526
761
  return create(grackle.EmptySchema, {});
527
762
  },
528
763
  // ─── Findings ────────────────────────────────────────────
529
764
  async postFinding(req) {
530
765
  const id = uuid().slice(0, 8);
531
766
  findingStore.postFinding(id, req.projectId, req.taskId, req.sessionId, req.category, req.title, req.content, [...req.tags]);
532
- broadcast({ type: "finding_posted", payload: { projectId: req.projectId, findingId: id } });
767
+ broadcast({
768
+ type: "finding_posted",
769
+ payload: { projectId: req.projectId, findingId: id },
770
+ });
533
771
  const rows = findingStore.queryFindings(req.projectId);
534
772
  const row = rows.find((r) => r.id === id);
535
773
  return findingRowToProto(row);
@@ -556,7 +794,8 @@ export function registerGrackleRoutes(router) {
556
794
  throw new Error(`Task not found: ${req.taskId}`);
557
795
  if (!task.branch)
558
796
  throw new Error("Task has no branch");
559
- const environmentId = task.environmentId || projectStore.getProject(task.projectId)?.defaultEnvironmentId;
797
+ const environmentId = task.environmentId ||
798
+ projectStore.getProject(task.projectId)?.defaultEnvironmentId;
560
799
  if (!environmentId)
561
800
  throw new Error("No environment for task");
562
801
  const conn = adapterManager.getConnection(environmentId);