@grackle-ai/server 0.14.10 → 0.15.1

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
- import type { ConnectRouter } from "@connectrpc/connect";
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,EAAsB,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AA4M7E,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,CAm5BjE"}
@@ -1,3 +1,4 @@
1
+ import { ConnectError, Code } from "@connectrpc/connect";
1
2
  import { create } from "@bufbuild/protobuf";
2
3
  import { grackle, powerline } from "@grackle-ai/common";
3
4
  import { v4 as uuid } from "uuid";
@@ -10,12 +11,14 @@ import * as tokenBroker from "./token-broker.js";
10
11
  import * as projectStore from "./project-store.js";
11
12
  import * as taskStore from "./task-store.js";
12
13
  import * as findingStore from "./finding-store.js";
14
+ import * as personaStore from "./persona-store.js";
13
15
  import { broadcast } from "./ws-broadcast.js";
14
16
  import { processEventStream } from "./event-processor.js";
15
17
  import { join } from "node:path";
16
18
  import { LOGS_DIR, DEFAULT_RUNTIME, DEFAULT_MODEL, MAX_TASK_DEPTH, taskStatusToEnum, taskStatusToString, projectStatusToEnum, } from "@grackle-ai/common";
17
19
  import { grackleHome } from "./paths.js";
18
20
  import { safeParseJsonArray } from "./json-helpers.js";
21
+ import { logger } from "./logger.js";
19
22
  import { slugify } from "./utils/slugify.js";
20
23
  import { buildTaskSystemContext } from "./utils/system-context.js";
21
24
  import { importGitHubIssues as executeGitHubImport } from "./github-import.js";
@@ -82,12 +85,87 @@ function taskRowToProto(row, childIds) {
82
85
  sortOrder: row.sortOrder,
83
86
  parentTaskId: row.parentTaskId,
84
87
  depth: row.depth,
85
- childTaskIds: childIds ?? taskStore.getChildren(row.id).map(c => c.id),
88
+ childTaskIds: childIds ?? taskStore.getChildren(row.id).map((c) => c.id),
86
89
  canDecompose: row.canDecompose,
90
+ personaId: row.personaId,
87
91
  });
88
92
  }
89
93
  function findingRowToProto(row) {
90
- return create(grackle.FindingSchema, { ...row, tags: safeParseJsonArray(row.tags) });
94
+ return create(grackle.FindingSchema, {
95
+ ...row,
96
+ tags: safeParseJsonArray(row.tags),
97
+ });
98
+ }
99
+ /** Safely parse a JSON string, returning the fallback value on failure. */
100
+ function safeParseJson(value, fallback) {
101
+ if (!value) {
102
+ return fallback;
103
+ }
104
+ try {
105
+ return JSON.parse(value);
106
+ }
107
+ catch {
108
+ return fallback;
109
+ }
110
+ }
111
+ /** Convert a persona database row to a Persona proto message. */
112
+ function personaRowToProto(row) {
113
+ const toolConfig = safeParseJson(row.toolConfig, {});
114
+ const mcpServers = safeParseJson(row.mcpServers, []);
115
+ return create(grackle.PersonaSchema, {
116
+ id: row.id,
117
+ name: row.name,
118
+ description: row.description,
119
+ systemPrompt: row.systemPrompt,
120
+ toolConfig: create(grackle.ToolConfigSchema, {
121
+ allowedTools: Array.isArray(toolConfig.allowedTools)
122
+ ? toolConfig.allowedTools.filter((t) => typeof t === "string")
123
+ : [],
124
+ disallowedTools: Array.isArray(toolConfig.disallowedTools)
125
+ ? toolConfig.disallowedTools.filter((t) => typeof t === "string")
126
+ : [],
127
+ }),
128
+ runtime: row.runtime,
129
+ model: row.model,
130
+ maxTurns: row.maxTurns,
131
+ mcpServers: mcpServers
132
+ .filter((s) => typeof s === "object" &&
133
+ s !== null &&
134
+ typeof s.name === "string" &&
135
+ typeof s.command === "string")
136
+ .map((s) => create(grackle.McpServerConfigSchema, {
137
+ name: s.name,
138
+ command: s.command,
139
+ args: Array.isArray(s.args)
140
+ ? s.args.filter((a) => typeof a === "string")
141
+ : [],
142
+ tools: Array.isArray(s.tools)
143
+ ? s.tools.filter((t) => typeof t === "string")
144
+ : [],
145
+ })),
146
+ createdAt: row.createdAt,
147
+ updatedAt: row.updatedAt,
148
+ });
149
+ }
150
+ /** Convert persona MCP server configs to a JSON string for the PowerLine SpawnRequest. */
151
+ function personaMcpServersToJson(row) {
152
+ const mcpServers = JSON.parse(row.mcpServers || "[]");
153
+ if (mcpServers.length === 0) {
154
+ return "";
155
+ }
156
+ return buildMcpServersJson(mcpServers);
157
+ }
158
+ /** Build a JSON string of MCP server configs for the PowerLine SpawnRequest. */
159
+ export function buildMcpServersJson(mcpServers) {
160
+ const obj = {};
161
+ for (const s of mcpServers) {
162
+ obj[s.name] = {
163
+ command: s.command,
164
+ args: s.args || [],
165
+ ...(s.tools && s.tools.length > 0 ? { tools: s.tools } : {}),
166
+ };
167
+ }
168
+ return JSON.stringify(obj);
91
169
  }
92
170
  /** Register all Grackle gRPC service handlers on the given ConnectRPC router. */
93
171
  export function registerGrackleRoutes(router) {
@@ -114,14 +192,19 @@ export function registerGrackleRoutes(router) {
114
192
  try {
115
193
  await adapter.disconnect(req.id);
116
194
  }
117
- catch { /* best-effort */ }
195
+ catch {
196
+ /* best-effort */
197
+ }
118
198
  }
119
199
  }
120
200
  adapterManager.removeConnection(req.id);
121
201
  // Delete sessions referencing this environment (FK constraint)
122
202
  sessionStore.deleteByEnvironment(req.id);
123
203
  envRegistry.removeEnvironment(req.id);
124
- broadcast({ type: "environment_removed", payload: { environmentId: req.id } });
204
+ broadcast({
205
+ type: "environment_removed",
206
+ payload: { environmentId: req.id },
207
+ });
125
208
  return create(grackle.EmptySchema, {});
126
209
  },
127
210
  async *provisionEnvironment(req) {
@@ -210,22 +293,42 @@ export function registerGrackleRoutes(router) {
210
293
  if (!conn) {
211
294
  throw new Error(`Environment ${req.environmentId} not connected`);
212
295
  }
296
+ // Resolve persona if specified
297
+ const persona = req.personaId
298
+ ? personaStore.getPersona(req.personaId)
299
+ : undefined;
300
+ if (req.personaId && !persona) {
301
+ throw new Error(`Persona not found: ${req.personaId}`);
302
+ }
213
303
  const sessionId = uuid();
214
- const runtime = req.runtime || env.defaultRuntime;
215
- const model = req.model || process.env.GRACKLE_DEFAULT_MODEL || DEFAULT_MODEL;
304
+ const runtime = req.runtime || persona?.runtime || env.defaultRuntime;
305
+ const model = req.model ||
306
+ persona?.model ||
307
+ process.env.GRACKLE_DEFAULT_MODEL ||
308
+ DEFAULT_MODEL;
216
309
  const logPath = join(grackleHome, LOGS_DIR, sessionId);
310
+ let systemContext = req.systemContext || "";
311
+ if (persona) {
312
+ systemContext =
313
+ persona.systemPrompt + (systemContext ? "\n\n" + systemContext : "");
314
+ }
217
315
  sessionStore.createSession(sessionId, req.environmentId, runtime, req.prompt, model, logPath);
316
+ const mcpServersJson = persona ? personaMcpServersToJson(persona) : "";
218
317
  const powerlineReq = create(powerline.SpawnRequestSchema, {
219
318
  sessionId,
220
319
  runtime,
221
320
  prompt: req.prompt,
222
321
  model,
223
- maxTurns: 0,
322
+ maxTurns: persona?.maxTurns || 0,
224
323
  branch: req.branch || "",
225
324
  worktreeBasePath: req.branch ? "/workspace" : "",
226
- systemContext: req.systemContext || "",
325
+ systemContext,
326
+ mcpServersJson,
327
+ });
328
+ processEventStream(conn.client.spawn(powerlineReq), {
329
+ sessionId,
330
+ logPath,
227
331
  });
228
- processEventStream(conn.client.spawn(powerlineReq), { sessionId, logPath });
229
332
  const row = sessionStore.getSession(sessionId);
230
333
  return sessionRowToProto(row);
231
334
  },
@@ -244,7 +347,10 @@ export function registerGrackleRoutes(router) {
244
347
  runtime: session.runtime,
245
348
  });
246
349
  const logPath = session.logPath || join(grackleHome, LOGS_DIR, session.id);
247
- processEventStream(conn.client.resume(powerlineReq), { sessionId: session.id, logPath });
350
+ processEventStream(conn.client.resume(powerlineReq), {
351
+ sessionId: session.id,
352
+ logPath,
353
+ });
248
354
  const row = sessionStore.getSession(session.id);
249
355
  return sessionRowToProto(row);
250
356
  },
@@ -374,7 +480,7 @@ export function registerGrackleRoutes(router) {
374
480
  const rows = taskStore.listTasks(req.id);
375
481
  const childIdsMap = taskStore.buildChildIdsMap(rows);
376
482
  return create(grackle.TaskListSchema, {
377
- tasks: rows.map(r => taskRowToProto(r, childIdsMap.get(r.id) ?? [])),
483
+ tasks: rows.map((r) => taskRowToProto(r, childIdsMap.get(r.id) ?? [])),
378
484
  });
379
485
  },
380
486
  async createTask(req) {
@@ -394,10 +500,23 @@ export function registerGrackleRoutes(router) {
394
500
  }
395
501
  }
396
502
  const id = uuid().slice(0, 8);
397
- 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);
503
+ // Resolve environment: explicit > parent task's env > project default
504
+ let environmentId = req.environmentId;
505
+ if (!environmentId && req.parentTaskId) {
506
+ const parent = taskStore.getTask(req.parentTaskId);
507
+ if (parent?.environmentId) {
508
+ environmentId = parent.environmentId;
509
+ }
510
+ }
511
+ if (!environmentId) {
512
+ environmentId = project.defaultEnvironmentId;
513
+ }
514
+ taskStore.createTask(id, req.projectId, req.title, req.description, environmentId, [...req.dependsOn], slugify(project.name), req.parentTaskId, req.canDecompose, req.personaId);
399
515
  const row = taskStore.getTask(id);
400
- broadcast({ type: "task_created", payload: { task: row ? { ...row } : null } });
516
+ broadcast({
517
+ type: "task_created",
518
+ payload: { task: row ? { ...row } : null },
519
+ });
401
520
  return taskRowToProto(row);
402
521
  },
403
522
  async getTask(req) {
@@ -418,7 +537,9 @@ export function registerGrackleRoutes(router) {
418
537
  }
419
538
  reqStatus = converted;
420
539
  }
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);
540
+ 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
541
+ ? [...req.dependsOn]
542
+ : safeParseJsonArray(existing.dependsOn), req.reviewNotes !== "" ? req.reviewNotes : existing.reviewNotes);
422
543
  const row = taskStore.getTask(req.id);
423
544
  return taskRowToProto(row);
424
545
  },
@@ -441,27 +562,50 @@ export function registerGrackleRoutes(router) {
441
562
  const conn = adapterManager.getConnection(environmentId);
442
563
  if (!conn)
443
564
  throw new Error(`Environment ${environmentId} not connected`);
565
+ // Resolve persona (StartTaskRequest override > task's stored persona)
566
+ const personaId = req.personaId || task.personaId;
567
+ const persona = personaId
568
+ ? personaStore.getPersona(personaId)
569
+ : undefined;
570
+ if (personaId && !persona) {
571
+ throw new Error(`Persona not found: ${personaId}`);
572
+ }
444
573
  const env = envRegistry.getEnvironment(environmentId);
445
574
  const sessionId = uuid();
446
- const runtime = req.runtime || env?.defaultRuntime || DEFAULT_RUNTIME;
447
- const model = req.model || process.env.GRACKLE_DEFAULT_MODEL || DEFAULT_MODEL;
575
+ const runtime = req.runtime ||
576
+ persona?.runtime ||
577
+ env?.defaultRuntime ||
578
+ DEFAULT_RUNTIME;
579
+ const model = req.model ||
580
+ persona?.model ||
581
+ process.env.GRACKLE_DEFAULT_MODEL ||
582
+ DEFAULT_MODEL;
583
+ const maxTurns = persona?.maxTurns || 0;
448
584
  const logPath = join(grackleHome, LOGS_DIR, sessionId);
449
- const systemContext = buildTaskSystemContext(task.title, task.description, task.reviewNotes, task.canDecompose);
585
+ let systemContext = buildTaskSystemContext(task.title, task.description, task.reviewNotes, task.canDecompose);
586
+ if (persona) {
587
+ systemContext = persona.systemPrompt + "\n\n" + systemContext;
588
+ }
450
589
  sessionStore.createSession(sessionId, environmentId, runtime, task.title, model, logPath);
451
590
  taskStore.setTaskSession(task.id, sessionId);
452
591
  taskStore.markTaskStarted(task.id);
453
- broadcast({ type: "task_started", payload: { taskId: task.id, sessionId, projectId: task.projectId } });
592
+ broadcast({
593
+ type: "task_started",
594
+ payload: { taskId: task.id, sessionId, projectId: task.projectId },
595
+ });
596
+ const mcpServersJson = persona ? personaMcpServersToJson(persona) : "";
454
597
  const powerlineReq = create(powerline.SpawnRequestSchema, {
455
598
  sessionId,
456
599
  runtime,
457
600
  prompt: task.title,
458
601
  model,
459
- maxTurns: 0,
602
+ maxTurns,
460
603
  branch: task.branch,
461
604
  worktreeBasePath: task.branch ? "/workspace" : "",
462
605
  systemContext,
463
606
  projectId: task.projectId,
464
607
  taskId: task.id,
608
+ mcpServersJson,
465
609
  });
466
610
  processEventStream(conn.client.spawn(powerlineReq), {
467
611
  sessionId,
@@ -479,7 +623,10 @@ export function registerGrackleRoutes(router) {
479
623
  else if (sess?.status === "failed") {
480
624
  taskStore.markTaskCompleted(task.id, "failed");
481
625
  }
482
- broadcast({ type: "task_updated", payload: { taskId: task.id, projectId: task.projectId } });
626
+ broadcast({
627
+ type: "task_updated",
628
+ payload: { taskId: task.id, projectId: task.projectId },
629
+ });
483
630
  }
484
631
  },
485
632
  });
@@ -498,11 +645,18 @@ export function registerGrackleRoutes(router) {
498
645
  sessionId: "",
499
646
  type: grackle.EventType.SYSTEM,
500
647
  timestamp: new Date().toISOString(),
501
- content: JSON.stringify({ type: "task_unblocked", taskId: t.id, title: t.title }),
648
+ content: JSON.stringify({
649
+ type: "task_unblocked",
650
+ taskId: t.id,
651
+ title: t.title,
652
+ }),
502
653
  raw: "",
503
654
  }));
504
655
  }
505
- broadcast({ type: "task_approved", payload: { taskId: task.id, projectId: task.projectId } });
656
+ broadcast({
657
+ type: "task_approved",
658
+ payload: { taskId: task.id, projectId: task.projectId },
659
+ });
506
660
  const row = taskStore.getTask(task.id);
507
661
  return taskRowToProto(row);
508
662
  },
@@ -511,25 +665,151 @@ export function registerGrackleRoutes(router) {
511
665
  if (!task)
512
666
  throw new Error(`Task not found: ${req.id}`);
513
667
  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 } });
668
+ broadcast({
669
+ type: "task_rejected",
670
+ payload: { taskId: task.id, projectId: task.projectId },
671
+ });
515
672
  const row = taskStore.getTask(task.id);
516
673
  return taskRowToProto(row);
517
674
  },
518
675
  async deleteTask(req) {
519
676
  const task = taskStore.getTask(req.id);
677
+ if (!task) {
678
+ throw new ConnectError(`Task not found: ${req.id}`, Code.NotFound);
679
+ }
520
680
  const children = taskStore.getChildren(req.id);
521
681
  if (children.length > 0) {
522
- throw new Error("Cannot delete task with children. Delete children first.");
682
+ throw new ConnectError("Cannot delete task with children. Delete children first.", Code.FailedPrecondition);
683
+ }
684
+ // Kill active session before deleting the task
685
+ if (task.sessionId) {
686
+ const activeSession = sessionStore.getSession(task.sessionId);
687
+ if (activeSession && (activeSession.status === "running" || activeSession.status === "waiting_input")) {
688
+ const conn = adapterManager.getConnection(activeSession.environmentId);
689
+ if (conn) {
690
+ try {
691
+ await conn.client.kill(create(powerline.SessionIdSchema, { id: task.sessionId }));
692
+ }
693
+ catch (err) {
694
+ logger.warn({ taskId: req.id, sessionId: task.sessionId, err }, "Failed to kill session during task deletion");
695
+ }
696
+ }
697
+ sessionStore.updateSession(task.sessionId, "killed");
698
+ streamHub.publish(create(grackle.SessionEventSchema, {
699
+ sessionId: task.sessionId,
700
+ type: grackle.EventType.STATUS,
701
+ timestamp: new Date().toISOString(),
702
+ content: "killed",
703
+ raw: "",
704
+ }));
705
+ }
706
+ }
707
+ const changes = taskStore.deleteTask(req.id);
708
+ if (changes === 0) {
709
+ logger.error({ taskId: req.id }, "deleteTask returned 0 changes despite task existing");
710
+ throw new ConnectError(`Failed to delete task ${req.id}: no rows affected`, Code.Internal);
711
+ }
712
+ broadcast({
713
+ type: "task_deleted",
714
+ payload: { taskId: req.id, projectId: task.projectId },
715
+ });
716
+ return create(grackle.EmptySchema, {});
717
+ },
718
+ // ─── Personas ───────────────────────────────────────────────
719
+ async listPersonas() {
720
+ const rows = personaStore.listPersonas();
721
+ return create(grackle.PersonaListSchema, {
722
+ personas: rows.map(personaRowToProto),
723
+ });
724
+ },
725
+ async createPersona(req) {
726
+ if (!req.name)
727
+ throw new Error("Persona name is required");
728
+ if (!req.systemPrompt)
729
+ throw new Error("Persona system_prompt is required");
730
+ // Enforce unique ID and unique name
731
+ let id = slugify(req.name) || uuid().slice(0, 8);
732
+ if (personaStore.getPersona(id)) {
733
+ id = `${id}-${uuid().slice(0, 4)}`;
523
734
  }
524
- taskStore.deleteTask(req.id);
525
- broadcast({ type: "task_deleted", payload: { taskId: req.id, projectId: task?.projectId } });
735
+ if (personaStore.getPersonaByName(req.name)) {
736
+ throw new Error(`Persona with name "${req.name}" already exists`);
737
+ }
738
+ const toolConfigJson = JSON.stringify({
739
+ allowedTools: [...(req.toolConfig?.allowedTools || [])],
740
+ disallowedTools: [...(req.toolConfig?.disallowedTools || [])],
741
+ });
742
+ const mcpServersJson = JSON.stringify(req.mcpServers.map((s) => ({
743
+ name: s.name,
744
+ command: s.command,
745
+ args: [...s.args],
746
+ tools: [...s.tools],
747
+ })));
748
+ personaStore.createPersona(id, req.name, req.description, req.systemPrompt, toolConfigJson, req.runtime, req.model, req.maxTurns, mcpServersJson);
749
+ broadcast({ type: "persona_created", payload: { personaId: id } });
750
+ const row = personaStore.getPersona(id);
751
+ return personaRowToProto(row);
752
+ },
753
+ async getPersona(req) {
754
+ const row = personaStore.getPersona(req.id);
755
+ if (!row)
756
+ throw new Error(`Persona not found: ${req.id}`);
757
+ return personaRowToProto(row);
758
+ },
759
+ async updatePersona(req) {
760
+ const existing = personaStore.getPersona(req.id);
761
+ if (!existing)
762
+ throw new Error(`Persona not found: ${req.id}`);
763
+ // Only update toolConfig/mcpServers if the request provides non-empty values;
764
+ // otherwise keep the existing stored value.
765
+ const hasNewToolConfig = !!req.toolConfig &&
766
+ ((req.toolConfig.allowedTools &&
767
+ req.toolConfig.allowedTools.length > 0) ||
768
+ (req.toolConfig.disallowedTools &&
769
+ req.toolConfig.disallowedTools.length > 0));
770
+ const toolConfigJson = hasNewToolConfig
771
+ ? JSON.stringify({
772
+ allowedTools: [...(req.toolConfig?.allowedTools || [])],
773
+ disallowedTools: [...(req.toolConfig?.disallowedTools || [])],
774
+ })
775
+ : existing.toolConfig;
776
+ const hasNewMcpServers = Array.isArray(req.mcpServers) && req.mcpServers.length > 0;
777
+ const mcpServersJson = hasNewMcpServers
778
+ ? JSON.stringify(req.mcpServers.map((s) => ({
779
+ name: s.name,
780
+ command: s.command,
781
+ args: [...s.args],
782
+ tools: [...s.tools],
783
+ })))
784
+ : existing.mcpServers;
785
+ // Treat empty string / 0 as "not set" and keep existing value
786
+ const name = req.name || existing.name;
787
+ if (name !== existing.name && personaStore.getPersonaByName(name)) {
788
+ throw new Error(`Persona with name "${name}" already exists`);
789
+ }
790
+ const description = req.description || existing.description;
791
+ const systemPrompt = req.systemPrompt || existing.systemPrompt;
792
+ const runtime = req.runtime || existing.runtime;
793
+ const model = req.model || existing.model;
794
+ const maxTurns = req.maxTurns === 0 ? existing.maxTurns : req.maxTurns;
795
+ personaStore.updatePersona(req.id, name, description, systemPrompt, toolConfigJson, runtime, model, maxTurns, mcpServersJson);
796
+ broadcast({ type: "persona_updated", payload: { personaId: req.id } });
797
+ const row = personaStore.getPersona(req.id);
798
+ return personaRowToProto(row);
799
+ },
800
+ async deletePersona(req) {
801
+ personaStore.deletePersona(req.id);
802
+ broadcast({ type: "persona_deleted", payload: { personaId: req.id } });
526
803
  return create(grackle.EmptySchema, {});
527
804
  },
528
805
  // ─── Findings ────────────────────────────────────────────
529
806
  async postFinding(req) {
530
807
  const id = uuid().slice(0, 8);
531
808
  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 } });
809
+ broadcast({
810
+ type: "finding_posted",
811
+ payload: { projectId: req.projectId, findingId: id },
812
+ });
533
813
  const rows = findingStore.queryFindings(req.projectId);
534
814
  const row = rows.find((r) => r.id === id);
535
815
  return findingRowToProto(row);
@@ -556,7 +836,8 @@ export function registerGrackleRoutes(router) {
556
836
  throw new Error(`Task not found: ${req.taskId}`);
557
837
  if (!task.branch)
558
838
  throw new Error("Task has no branch");
559
- const environmentId = task.environmentId || projectStore.getProject(task.projectId)?.defaultEnvironmentId;
839
+ const environmentId = task.environmentId ||
840
+ projectStore.getProject(task.projectId)?.defaultEnvironmentId;
560
841
  if (!environmentId)
561
842
  throw new Error("No environment for task");
562
843
  const conn = adapterManager.getConnection(environmentId);