@grackle-ai/server 0.39.1 → 0.41.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.
Files changed (62) hide show
  1. package/dist/db.d.ts.map +1 -1
  2. package/dist/db.js +62 -0
  3. package/dist/db.js.map +1 -1
  4. package/dist/env-registry.d.ts +1 -1
  5. package/dist/env-registry.d.ts.map +1 -1
  6. package/dist/env-registry.js +1 -2
  7. package/dist/env-registry.js.map +1 -1
  8. package/dist/event-bus.d.ts +37 -0
  9. package/dist/event-bus.d.ts.map +1 -0
  10. package/dist/event-bus.js +65 -0
  11. package/dist/event-bus.js.map +1 -0
  12. package/dist/event-processor.d.ts.map +1 -1
  13. package/dist/event-processor.js +14 -11
  14. package/dist/event-processor.js.map +1 -1
  15. package/dist/event-store.d.ts +9 -0
  16. package/dist/event-store.d.ts.map +1 -0
  17. package/dist/event-store.js +16 -0
  18. package/dist/event-store.js.map +1 -0
  19. package/dist/github-import.js +3 -5
  20. package/dist/github-import.js.map +1 -1
  21. package/dist/grpc-service.d.ts.map +1 -1
  22. package/dist/grpc-service.js +106 -129
  23. package/dist/grpc-service.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +10 -7
  26. package/dist/index.js.map +1 -1
  27. package/dist/project-store.d.ts +3 -1
  28. package/dist/project-store.d.ts.map +1 -1
  29. package/dist/project-store.js +5 -1
  30. package/dist/project-store.js.map +1 -1
  31. package/dist/reanimate-agent.d.ts +12 -0
  32. package/dist/reanimate-agent.d.ts.map +1 -0
  33. package/dist/reanimate-agent.js +78 -0
  34. package/dist/reanimate-agent.js.map +1 -0
  35. package/dist/resolve-persona.d.ts +29 -0
  36. package/dist/resolve-persona.d.ts.map +1 -0
  37. package/dist/resolve-persona.js +40 -0
  38. package/dist/resolve-persona.js.map +1 -0
  39. package/dist/schema.d.ts +123 -0
  40. package/dist/schema.d.ts.map +1 -1
  41. package/dist/schema.js +9 -0
  42. package/dist/schema.js.map +1 -1
  43. package/dist/session-store.d.ts +6 -1
  44. package/dist/session-store.d.ts.map +1 -1
  45. package/dist/session-store.js +23 -5
  46. package/dist/session-store.js.map +1 -1
  47. package/dist/settings-store.d.ts +14 -0
  48. package/dist/settings-store.d.ts.map +1 -0
  49. package/dist/settings-store.js +29 -0
  50. package/dist/settings-store.js.map +1 -0
  51. package/dist/task-store.d.ts +2 -2
  52. package/dist/task-store.d.ts.map +1 -1
  53. package/dist/task-store.js +10 -5
  54. package/dist/task-store.js.map +1 -1
  55. package/dist/ws-bridge.d.ts.map +1 -1
  56. package/dist/ws-bridge.js +163 -164
  57. package/dist/ws-bridge.js.map +1 -1
  58. package/dist/ws-broadcast.d.ts +5 -0
  59. package/dist/ws-broadcast.d.ts.map +1 -1
  60. package/dist/ws-broadcast.js +24 -1
  61. package/dist/ws-broadcast.js.map +1 -1
  62. package/package.json +7 -6
@@ -12,17 +12,21 @@ import * as projectStore from "./project-store.js";
12
12
  import * as taskStore from "./task-store.js";
13
13
  import * as findingStore from "./finding-store.js";
14
14
  import * as personaStore from "./persona-store.js";
15
- import { broadcast, broadcastEnvironments } from "./ws-broadcast.js";
15
+ import { emit } from "./event-bus.js";
16
16
  import { processEventStream } from "./event-processor.js";
17
17
  import * as processorRegistry from "./processor-registry.js";
18
18
  import { join } from "node:path";
19
- import { LOGS_DIR, DEFAULT_RUNTIME, DEFAULT_MODEL, DEFAULT_WEB_PORT, DEFAULT_MCP_PORT, MAX_TASK_DEPTH, SESSION_STATUS, TASK_STATUS, taskStatusToEnum, taskStatusToString, projectStatusToEnum, claudeProviderModeToEnum, providerToggleToEnum, } from "@grackle-ai/common";
19
+ import { LOGS_DIR, DEFAULT_WEB_PORT, DEFAULT_MCP_PORT, MAX_TASK_DEPTH, SESSION_STATUS, TASK_STATUS, taskStatusToEnum, taskStatusToString, projectStatusToEnum, claudeProviderModeToEnum, providerToggleToEnum, } from "@grackle-ai/common";
20
+ import { resolvePersona } from "./resolve-persona.js";
21
+ import * as settingsStore from "./settings-store.js";
22
+ import { isAllowedSettingKey } from "./settings-store.js";
20
23
  import { createScopedToken } from "@grackle-ai/mcp";
21
24
  import { grackleHome } from "./paths.js";
22
25
  import { safeParseJsonArray } from "./json-helpers.js";
23
26
  import { computeTaskStatus } from "./compute-task-status.js";
24
27
  import { loadOrCreateApiKey } from "./api-key.js";
25
28
  import { logger } from "./logger.js";
29
+ import { reanimateAgent } from "./reanimate-agent.js";
26
30
  import { slugify } from "./utils/slugify.js";
27
31
  import { buildTaskSystemContext } from "./utils/system-context.js";
28
32
  import { importGitHubIssues as executeGitHubImport } from "./github-import.js";
@@ -50,7 +54,6 @@ function envRowToProto(row) {
50
54
  displayName: row.displayName,
51
55
  adapterType: row.adapterType,
52
56
  adapterConfig: row.adapterConfig,
53
- defaultRuntime: row.defaultRuntime,
54
57
  bootstrapped: row.bootstrapped,
55
58
  status: row.status,
56
59
  lastSeen: row.lastSeen || "",
@@ -89,6 +92,7 @@ function projectRowToProto(row) {
89
92
  updatedAt: row.updatedAt,
90
93
  useWorktrees: row.useWorktrees,
91
94
  worktreeBasePath: row.worktreeBasePath,
95
+ defaultPersonaId: row.defaultPersonaId,
92
96
  });
93
97
  }
94
98
  function taskRowToProto(row, childIds, computedStatus, latestSessionId) {
@@ -110,6 +114,7 @@ function taskRowToProto(row, childIds, computedStatus, latestSessionId) {
110
114
  depth: row.depth,
111
115
  childTaskIds: childIds ?? taskStore.getChildren(row.id).map((c) => c.id),
112
116
  canDecompose: row.canDecompose,
117
+ defaultPersonaId: row.defaultPersonaId,
113
118
  });
114
119
  }
115
120
  function findingRowToProto(row) {
@@ -173,8 +178,15 @@ function personaRowToProto(row) {
173
178
  }
174
179
  /** Convert persona MCP server configs to a JSON string for the PowerLine SpawnRequest. */
175
180
  function personaMcpServersToJson(row) {
176
- const mcpServers = JSON.parse(row.mcpServers || "[]");
177
- if (mcpServers.length === 0) {
181
+ let mcpServers;
182
+ try {
183
+ mcpServers = JSON.parse(row.mcpServers || "[]");
184
+ }
185
+ catch {
186
+ logger.warn({ personaId: row.id }, "Failed to parse persona mcpServers JSON; ignoring");
187
+ return "";
188
+ }
189
+ if (!Array.isArray(mcpServers) || mcpServers.length === 0) {
178
190
  return "";
179
191
  }
180
192
  return buildMcpServersJson(mcpServers);
@@ -202,9 +214,8 @@ export function registerGrackleRoutes(router) {
202
214
  },
203
215
  async addEnvironment(req) {
204
216
  const id = req.displayName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
205
- const runtime = req.defaultRuntime || DEFAULT_RUNTIME;
206
- envRegistry.addEnvironment(id, req.displayName, req.adapterType, req.adapterConfig, runtime);
207
- broadcastEnvironments();
217
+ envRegistry.addEnvironment(id, req.displayName, req.adapterType, req.adapterConfig);
218
+ emit("environment.changed", {});
208
219
  const row = envRegistry.getEnvironment(id);
209
220
  return envRowToProto(row);
210
221
  },
@@ -226,11 +237,8 @@ export function registerGrackleRoutes(router) {
226
237
  // Delete sessions referencing this environment (FK constraint)
227
238
  sessionStore.deleteByEnvironment(req.id);
228
239
  envRegistry.removeEnvironment(req.id);
229
- broadcastEnvironments();
230
- broadcast({
231
- type: "environment_removed",
232
- payload: { environmentId: req.id },
233
- });
240
+ emit("environment.changed", {});
241
+ emit("environment.removed", { environmentId: req.id });
234
242
  return create(grackle.EmptySchema, {});
235
243
  },
236
244
  async *provisionEnvironment(req) {
@@ -253,7 +261,7 @@ export function registerGrackleRoutes(router) {
253
261
  return;
254
262
  }
255
263
  envRegistry.updateEnvironmentStatus(req.id, "connecting");
256
- broadcastEnvironments();
264
+ emit("environment.changed", {});
257
265
  const config = JSON.parse(env.adapterConfig);
258
266
  const powerlineToken = env.powerlineToken;
259
267
  try {
@@ -268,7 +276,7 @@ export function registerGrackleRoutes(router) {
268
276
  catch (err) {
269
277
  logger.error({ environmentId: req.id, err }, "Provision/bootstrap failed");
270
278
  envRegistry.updateEnvironmentStatus(req.id, "error");
271
- broadcastEnvironments();
279
+ emit("environment.changed", {});
272
280
  yield create(grackle.ProvisionEventSchema, {
273
281
  stage: "error",
274
282
  message: `Provision failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -283,7 +291,7 @@ export function registerGrackleRoutes(router) {
283
291
  await tokenBroker.pushToEnv(req.id);
284
292
  envRegistry.updateEnvironmentStatus(req.id, "connected");
285
293
  envRegistry.markBootstrapped(req.id);
286
- broadcastEnvironments();
294
+ emit("environment.changed", {});
287
295
  yield create(grackle.ProvisionEventSchema, {
288
296
  stage: "ready",
289
297
  message: "Environment connected",
@@ -292,7 +300,7 @@ export function registerGrackleRoutes(router) {
292
300
  }
293
301
  catch (err) {
294
302
  envRegistry.updateEnvironmentStatus(req.id, "error");
295
- broadcastEnvironments();
303
+ emit("environment.changed", {});
296
304
  yield create(grackle.ProvisionEventSchema, {
297
305
  stage: "error",
298
306
  message: `Connection failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -311,7 +319,7 @@ export function registerGrackleRoutes(router) {
311
319
  }
312
320
  adapterManager.removeConnection(req.id);
313
321
  envRegistry.updateEnvironmentStatus(req.id, "disconnected");
314
- broadcastEnvironments();
322
+ emit("environment.changed", {});
315
323
  return create(grackle.EmptySchema, {});
316
324
  },
317
325
  async destroyEnvironment(req) {
@@ -325,7 +333,7 @@ export function registerGrackleRoutes(router) {
325
333
  }
326
334
  adapterManager.removeConnection(req.id);
327
335
  envRegistry.updateEnvironmentStatus(req.id, "disconnected");
328
- broadcastEnvironments();
336
+ emit("environment.changed", {});
329
337
  return create(grackle.EmptySchema, {});
330
338
  },
331
339
  async spawnAgent(req) {
@@ -337,37 +345,35 @@ export function registerGrackleRoutes(router) {
337
345
  if (!conn) {
338
346
  throw new ConnectError(`Environment ${req.environmentId} not connected`, Code.FailedPrecondition);
339
347
  }
340
- // Resolve persona if specified
341
- const persona = req.personaId
342
- ? personaStore.getPersona(req.personaId)
343
- : undefined;
344
- if (req.personaId && !persona) {
345
- throw new ConnectError(`Persona not found: ${req.personaId}`, Code.NotFound);
348
+ // Resolve persona via cascade (request → app default)
349
+ let resolved;
350
+ try {
351
+ resolved = resolvePersona(req.personaId);
352
+ }
353
+ catch (err) {
354
+ throw new ConnectError(err.message, Code.FailedPrecondition);
346
355
  }
347
356
  const sessionId = uuid();
348
- const runtime = req.runtime || persona?.runtime || env.defaultRuntime;
349
- const model = req.model ||
350
- persona?.model ||
351
- process.env.GRACKLE_DEFAULT_MODEL ||
352
- DEFAULT_MODEL;
357
+ const { runtime, model, systemPrompt, persona } = resolved;
358
+ const maxTurns = req.maxTurns || resolved.maxTurns;
353
359
  const logPath = join(grackleHome, LOGS_DIR, sessionId);
354
360
  let systemContext = req.systemContext || "";
355
- if (persona) {
361
+ if (systemPrompt) {
356
362
  systemContext =
357
- persona.systemPrompt + (systemContext ? "\n\n" + systemContext : "");
363
+ systemPrompt + (systemContext ? "\n\n" + systemContext : "");
358
364
  }
359
365
  sessionStore.createSession(sessionId, req.environmentId, runtime, req.prompt, model, logPath);
360
- const mcpServersJson = persona ? personaMcpServersToJson(persona) : "";
366
+ const mcpServersJson = personaMcpServersToJson(persona);
361
367
  const mcpPort = parseInt(process.env.GRACKLE_MCP_PORT || String(DEFAULT_MCP_PORT), 10);
362
368
  const mcpDialHost = toDialableHost(process.env.GRACKLE_HOST || "127.0.0.1");
363
369
  const mcpUrl = `http://${mcpDialHost}:${mcpPort}/mcp`;
364
- const mcpToken = createScopedToken({ sub: sessionId, pid: "", per: req.personaId || "", sid: sessionId }, loadOrCreateApiKey());
370
+ const mcpToken = createScopedToken({ sub: sessionId, pid: "", per: resolved.personaId, sid: sessionId }, loadOrCreateApiKey());
365
371
  const powerlineReq = create(powerline.SpawnRequestSchema, {
366
372
  sessionId,
367
373
  runtime,
368
374
  prompt: req.prompt,
369
375
  model,
370
- maxTurns: persona?.maxTurns || 0,
376
+ maxTurns,
371
377
  branch: req.branch,
372
378
  worktreeBasePath: req.branch
373
379
  ? (req.worktreeBasePath.trim() || process.env.GRACKLE_WORKTREE_BASE || "/workspace")
@@ -388,37 +394,7 @@ export function registerGrackleRoutes(router) {
388
394
  return sessionRowToProto(row);
389
395
  },
390
396
  async resumeAgent(req) {
391
- const session = sessionStore.getSession(req.sessionId);
392
- if (!session) {
393
- throw new ConnectError(`Session not found: ${req.sessionId}`, Code.NotFound);
394
- }
395
- const conn = adapterManager.getConnection(session.environmentId);
396
- if (!conn) {
397
- throw new ConnectError(`Environment ${session.environmentId} not connected`, Code.FailedPrecondition);
398
- }
399
- const powerlineReq = create(powerline.ResumeRequestSchema, {
400
- sessionId: session.id,
401
- runtimeSessionId: session.runtimeSessionId || "",
402
- runtime: session.runtime,
403
- });
404
- const logPath = session.logPath || join(grackleHome, LOGS_DIR, session.id);
405
- // Auto-bind task context from DB if session was previously associated with a task
406
- let resumeProjectId;
407
- let resumeTaskId;
408
- if (session.taskId) {
409
- const task = taskStore.getTask(session.taskId);
410
- if (task) {
411
- resumeProjectId = task.projectId;
412
- resumeTaskId = task.id;
413
- }
414
- }
415
- processEventStream(conn.client.resume(powerlineReq), {
416
- sessionId: session.id,
417
- logPath,
418
- projectId: resumeProjectId,
419
- taskId: resumeTaskId,
420
- });
421
- const row = sessionStore.getSession(session.id);
397
+ const row = reanimateAgent(req.sessionId);
422
398
  return sessionRowToProto(row);
423
399
  },
424
400
  async sendInput(req) {
@@ -465,7 +441,7 @@ export function registerGrackleRoutes(router) {
465
441
  if (session.taskId) {
466
442
  const task = taskStore.getTask(session.taskId);
467
443
  if (task) {
468
- broadcast({ type: "task_updated", payload: { taskId: task.id, projectId: task.projectId } });
444
+ emit("task.updated", { taskId: task.id, projectId: task.projectId });
469
445
  }
470
446
  }
471
447
  return create(grackle.EmptySchema, {});
@@ -555,10 +531,7 @@ export function registerGrackleRoutes(router) {
555
531
  const current = credentialProviders.getCredentialProviders();
556
532
  const updated = { ...current, [req.provider]: req.value };
557
533
  credentialProviders.setCredentialProviders(updated);
558
- broadcast({
559
- type: "credential_providers",
560
- payload: updated,
561
- });
534
+ emit("credential.providers_changed", updated);
562
535
  return create(grackle.CredentialProviderConfigSchema, {
563
536
  claude: claudeProviderModeToEnum(updated.claude),
564
537
  github: providerToggleToEnum(updated.github),
@@ -581,8 +554,8 @@ export function registerGrackleRoutes(router) {
581
554
  }
582
555
  // useWorktrees defaults to true when not specified
583
556
  const useWorktrees = req.useWorktrees ?? true;
584
- projectStore.createProject(id, req.name, req.description, req.repoUrl, req.defaultEnvironmentId, useWorktrees, req.worktreeBasePath ?? "");
585
- broadcast({ type: "project_created", payload: { projectId: id } });
557
+ projectStore.createProject(id, req.name, req.description, req.repoUrl, req.defaultEnvironmentId, useWorktrees, req.worktreeBasePath ?? "", req.defaultPersonaId ?? "");
558
+ emit("project.created", { projectId: id });
586
559
  const row = projectStore.getProject(id);
587
560
  return projectRowToProto(row);
588
561
  },
@@ -594,7 +567,7 @@ export function registerGrackleRoutes(router) {
594
567
  },
595
568
  async archiveProject(req) {
596
569
  projectStore.archiveProject(req.id);
597
- broadcast({ type: "project_archived", payload: { projectId: req.id } });
570
+ emit("project.archived", { projectId: req.id });
598
571
  return create(grackle.EmptySchema, {});
599
572
  },
600
573
  async updateProject(req) {
@@ -615,11 +588,12 @@ export function registerGrackleRoutes(router) {
615
588
  defaultEnvironmentId: req.defaultEnvironmentId,
616
589
  useWorktrees: req.useWorktrees ?? undefined,
617
590
  worktreeBasePath: req.worktreeBasePath,
591
+ defaultPersonaId: req.defaultPersonaId,
618
592
  });
619
593
  if (!row) {
620
594
  throw new ConnectError(`Project not found after update: ${req.id}`, Code.NotFound);
621
595
  }
622
- broadcast({ type: "project_updated", payload: { projectId: req.id } });
596
+ emit("project.updated", { projectId: req.id });
623
597
  return projectRowToProto(row);
624
598
  },
625
599
  // ─── Tasks ───────────────────────────────────────────────
@@ -666,12 +640,9 @@ export function registerGrackleRoutes(router) {
666
640
  taskStore.createTask(id, req.projectId, req.title, req.description, [...req.dependsOn], slugify(project.name), req.parentTaskId,
667
641
  // Default to false (no decomposition rights) unless explicitly granted.
668
642
  // Orchestrator/root processes that need fork() must opt in.
669
- req.canDecompose ?? false);
643
+ req.canDecompose ?? false, req.defaultPersonaId ?? "");
670
644
  const row = taskStore.getTask(id);
671
- broadcast({
672
- type: "task_created",
673
- payload: { task: row ? { ...row } : null },
674
- });
645
+ emit("task.created", { taskId: id, projectId: req.projectId });
675
646
  return taskRowToProto(row);
676
647
  },
677
648
  async getTask(req) {
@@ -696,7 +667,7 @@ export function registerGrackleRoutes(router) {
696
667
  }
697
668
  taskStore.updateTask(req.id, req.title !== "" ? req.title : existing.title, req.description !== "" ? req.description : existing.description, reqStatus, req.dependsOn.length > 0
698
669
  ? [...req.dependsOn]
699
- : safeParseJsonArray(existing.dependsOn));
670
+ : safeParseJsonArray(existing.dependsOn), req.defaultPersonaId);
700
671
  // Late-bind: associate an existing session with this task
701
672
  if (req.sessionId !== "") {
702
673
  const session = sessionStore.getSession(req.sessionId);
@@ -713,10 +684,7 @@ export function registerGrackleRoutes(router) {
713
684
  }
714
685
  sessionStore.setSessionTask(req.sessionId, req.id);
715
686
  processorRegistry.lateBind(req.sessionId, req.id, existing.projectId);
716
- broadcast({
717
- type: "task_started",
718
- payload: { taskId: req.id, sessionId: req.sessionId, projectId: existing.projectId },
719
- });
687
+ emit("task.started", { taskId: req.id, sessionId: req.sessionId, projectId: existing.projectId });
720
688
  }
721
689
  const row = taskStore.getTask(req.id);
722
690
  const taskSessions = sessionStore.listSessionsForTask(req.id);
@@ -747,39 +715,28 @@ export function registerGrackleRoutes(router) {
747
715
  const conn = adapterManager.getConnection(environmentId);
748
716
  if (!conn)
749
717
  throw new ConnectError(`Environment ${environmentId} not connected`, Code.FailedPrecondition);
750
- // Resolve persona from StartTaskRequest
751
- const personaId = req.personaId || "";
752
- const persona = personaId
753
- ? personaStore.getPersona(personaId)
754
- : undefined;
755
- if (personaId && !persona) {
756
- throw new ConnectError(`Persona not found: ${personaId}`, Code.NotFound);
718
+ // Resolve persona via cascade (request → task → project → app default)
719
+ let resolved;
720
+ try {
721
+ resolved = resolvePersona(req.personaId, task.defaultPersonaId, project.defaultPersonaId);
722
+ }
723
+ catch (err) {
724
+ throw new ConnectError(err.message, Code.FailedPrecondition);
757
725
  }
758
726
  const env = envRegistry.getEnvironment(environmentId);
759
727
  const sessionId = uuid();
760
- const runtime = req.runtime ||
761
- persona?.runtime ||
762
- env?.defaultRuntime ||
763
- DEFAULT_RUNTIME;
764
- const model = req.model ||
765
- persona?.model ||
766
- process.env.GRACKLE_DEFAULT_MODEL ||
767
- DEFAULT_MODEL;
768
- const maxTurns = persona?.maxTurns || 0;
728
+ const { runtime, model, maxTurns, systemPrompt, persona } = resolved;
769
729
  const logPath = join(grackleHome, LOGS_DIR, sessionId);
770
730
  let systemContext = buildTaskSystemContext(task.title, task.description, req.notes || "", task.canDecompose);
771
- if (persona) {
772
- systemContext = persona.systemPrompt + "\n\n" + systemContext;
731
+ if (systemPrompt) {
732
+ systemContext = systemPrompt + "\n\n" + systemContext;
773
733
  }
774
- sessionStore.createSession(sessionId, environmentId, runtime, task.title, model, logPath, task.id, personaId);
775
- broadcast({
776
- type: "task_started",
777
- payload: { taskId: task.id, sessionId, projectId: task.projectId },
778
- });
734
+ sessionStore.createSession(sessionId, environmentId, runtime, task.title, model, logPath, task.id, resolved.personaId);
735
+ emit("task.started", { taskId: task.id, sessionId, projectId: task.projectId });
779
736
  // Re-push stored tokens + provider credentials (scoped to runtime) so they're fresh for this session.
780
737
  // For local envs, skip file tokens — the PowerLine is on the same machine.
781
738
  await tokenBroker.refreshTokensForTask(environmentId, runtime, env?.adapterType === "local" ? { excludeFileTokens: true } : undefined);
782
- const mcpServersJson = persona ? personaMcpServersToJson(persona) : "";
739
+ const mcpServersJson = personaMcpServersToJson(persona);
783
740
  // When useWorktrees is false, omit worktreeBasePath so PowerLine checks
784
741
  // out the branch in the main working tree instead of creating a worktree.
785
742
  // The branch field is still populated so the agent knows its branch name.
@@ -790,7 +747,7 @@ export function registerGrackleRoutes(router) {
790
747
  const taskMcpPort = parseInt(process.env.GRACKLE_MCP_PORT || String(DEFAULT_MCP_PORT), 10);
791
748
  const taskMcpDialHost = toDialableHost(process.env.GRACKLE_HOST || "127.0.0.1");
792
749
  const taskMcpUrl = `http://${taskMcpDialHost}:${taskMcpPort}/mcp`;
793
- const taskMcpToken = createScopedToken({ sub: task.id, pid: task.projectId, per: personaId, sid: sessionId }, loadOrCreateApiKey());
750
+ const taskMcpToken = createScopedToken({ sub: task.id, pid: task.projectId, per: resolved.personaId, sid: sessionId }, loadOrCreateApiKey());
794
751
  const powerlineReq = create(powerline.SpawnRequestSchema, {
795
752
  sessionId,
796
753
  runtime,
@@ -837,10 +794,7 @@ export function registerGrackleRoutes(router) {
837
794
  raw: "",
838
795
  }));
839
796
  }
840
- broadcast({
841
- type: "task_completed",
842
- payload: { taskId: task.id, projectId: task.projectId },
843
- });
797
+ emit("task.completed", { taskId: task.id, projectId: task.projectId });
844
798
  const row = taskStore.getTask(task.id);
845
799
  const taskSessions = sessionStore.listSessionsForTask(task.id);
846
800
  const { status, latestSessionId } = computeTaskStatus(row.status, taskSessions);
@@ -876,10 +830,7 @@ export function registerGrackleRoutes(router) {
876
830
  projectId: task.projectId,
877
831
  taskId: task.id,
878
832
  });
879
- broadcast({
880
- type: "task_started",
881
- payload: { taskId: task.id, sessionId: latestSession.id, projectId: task.projectId },
882
- });
833
+ emit("task.started", { taskId: task.id, sessionId: latestSession.id, projectId: task.projectId });
883
834
  const row = sessionStore.getSession(latestSession.id);
884
835
  return sessionRowToProto(row);
885
836
  },
@@ -918,10 +869,7 @@ export function registerGrackleRoutes(router) {
918
869
  logger.error({ taskId: req.id }, "deleteTask returned 0 changes despite task existing");
919
870
  throw new ConnectError(`Failed to delete task ${req.id}: no rows affected`, Code.Internal);
920
871
  }
921
- broadcast({
922
- type: "task_deleted",
923
- payload: { taskId: req.id, projectId: task.projectId },
924
- });
872
+ emit("task.deleted", { taskId: req.id, projectId: task.projectId });
925
873
  return create(grackle.EmptySchema, {});
926
874
  },
927
875
  // ─── Personas ───────────────────────────────────────────────
@@ -955,7 +903,7 @@ export function registerGrackleRoutes(router) {
955
903
  tools: [...s.tools],
956
904
  })));
957
905
  personaStore.createPersona(id, req.name, req.description, req.systemPrompt, toolConfigJson, req.runtime, req.model, req.maxTurns, mcpServersJson);
958
- broadcast({ type: "persona_created", payload: { personaId: id } });
906
+ emit("persona.created", { personaId: id });
959
907
  const row = personaStore.getPersona(id);
960
908
  return personaRowToProto(row);
961
909
  },
@@ -1000,23 +948,52 @@ export function registerGrackleRoutes(router) {
1000
948
  const model = req.model || existing.model;
1001
949
  const maxTurns = req.maxTurns === 0 ? existing.maxTurns : req.maxTurns;
1002
950
  personaStore.updatePersona(req.id, name, description, systemPrompt, toolConfigJson, runtime, model, maxTurns, mcpServersJson);
1003
- broadcast({ type: "persona_updated", payload: { personaId: req.id } });
951
+ emit("persona.updated", { personaId: req.id });
1004
952
  const row = personaStore.getPersona(req.id);
1005
953
  return personaRowToProto(row);
1006
954
  },
1007
955
  async deletePersona(req) {
1008
956
  personaStore.deletePersona(req.id);
1009
- broadcast({ type: "persona_deleted", payload: { personaId: req.id } });
957
+ emit("persona.deleted", { personaId: req.id });
1010
958
  return create(grackle.EmptySchema, {});
1011
959
  },
960
+ // ─── Settings ─────────────────────────────────────────────
961
+ async getSetting(req) {
962
+ if (!isAllowedSettingKey(req.key)) {
963
+ throw new ConnectError(`Setting key not allowed: ${req.key}`, Code.InvalidArgument);
964
+ }
965
+ const value = settingsStore.getSetting(req.key);
966
+ return create(grackle.SettingResponseSchema, {
967
+ key: req.key,
968
+ value: value ?? "",
969
+ });
970
+ },
971
+ async setSetting(req) {
972
+ if (!isAllowedSettingKey(req.key)) {
973
+ throw new ConnectError(`Setting key not allowed: ${req.key}`, Code.InvalidArgument);
974
+ }
975
+ // Validate persona exists and has required fields when setting default_persona_id
976
+ if (req.key === "default_persona_id" && req.value) {
977
+ const persona = personaStore.getPersona(req.value);
978
+ if (!persona) {
979
+ throw new ConnectError(`Persona not found: ${req.value}`, Code.NotFound);
980
+ }
981
+ if (!persona.runtime || !persona.model) {
982
+ throw new ConnectError(`Persona "${persona.name}" must have runtime and model configured`, Code.FailedPrecondition);
983
+ }
984
+ }
985
+ settingsStore.setSetting(req.key, req.value);
986
+ emit("setting.changed", { key: req.key, value: req.value });
987
+ return create(grackle.SettingResponseSchema, {
988
+ key: req.key,
989
+ value: req.value,
990
+ });
991
+ },
1012
992
  // ─── Findings ────────────────────────────────────────────
1013
993
  async postFinding(req) {
1014
994
  const id = uuid().slice(0, 8);
1015
995
  findingStore.postFinding(id, req.projectId, req.taskId, req.sessionId, req.category, req.title, req.content, [...req.tags]);
1016
- broadcast({
1017
- type: "finding_posted",
1018
- payload: { projectId: req.projectId, findingId: id },
1019
- });
996
+ emit("finding.posted", { projectId: req.projectId, findingId: id });
1020
997
  const rows = findingStore.queryFindings(req.projectId);
1021
998
  const row = rows.find((r) => r.id === id);
1022
999
  return findingRowToProto(row);