pi-pstack-adapter 0.7.0 → 0.9.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/index.ts CHANGED
@@ -7,7 +7,17 @@ import { Type } from "typebox";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { registerDelegationActivation } from "./src/activation.ts";
9
9
  import { registerAgents, unregisteredAgentDiagnostics, type RuntimeRegistration } from "./src/agents.ts";
10
- import { DEFAULT_DELEGATION } from "./src/delegation.ts";
10
+ import { DEFAULT_DELEGATION, SUBAGENT_TOOL, T3_DELEGATE_TOOL } from "./src/delegation.ts";
11
+ import {
12
+ FILTER_ENTRY_TYPE,
13
+ type ModelFilter,
14
+ activeFilter,
15
+ describeOrigin,
16
+ filterOf,
17
+ parseModelFilter,
18
+ sessionFilterFromEntries,
19
+ } from "./src/filter.ts";
20
+ import { gateDelegation } from "./src/gate.ts";
11
21
  import { checkPrerequisites, detectCapabilities, type ToolDescriptor } from "./src/capabilities.ts";
12
22
  import {
13
23
  type AdapterConfig,
@@ -21,6 +31,8 @@ import {
21
31
  joinModelSpec,
22
32
  mergeConfig,
23
33
  planModelAssignment,
34
+ planPanelAssignment,
35
+ planPoolAssignment,
24
36
  readConfigFile,
25
37
  trustWarning,
26
38
  updateConfigFile,
@@ -30,8 +42,16 @@ import { safeEcho } from "./src/echo.ts";
30
42
  import { MODE_ENTRY_TYPE, modeFromEntries, modeInstruction } from "./src/mode.ts";
31
43
  import { CONFIG_FILE_NAME, agentDir, cacheRoot } from "./src/paths.ts";
32
44
  import { bashToolContext, probeExecutables } from "./src/executables.ts";
33
- import { entryFor } from "./src/registry.ts";
34
- import { platformSupport, renderCheck, renderStatus, shellDiagnostics, type ShellState } from "./src/status.ts";
45
+ import { SKILL_PANELS, entryFor, isPanelRole } from "./src/registry.ts";
46
+ import {
47
+ platformSupport,
48
+ renderCheck,
49
+ renderModelPolicy,
50
+ renderPanels,
51
+ renderStatus,
52
+ shellDiagnostics,
53
+ type ShellState,
54
+ } from "./src/status.ts";
35
55
  import { BootstrapRequiredError, resolveRefToCommit, resolveSource } from "./src/upstream.ts";
36
56
  import { normalizeRepo } from "./src/config.ts";
37
57
  import type { CapabilityStatus, Diagnostic, GeneratedAgent, GenerationResult, ResolvedSource } from "./src/types.ts";
@@ -55,6 +75,11 @@ export default function (pi: ExtensionAPI) {
55
75
  let agentDiagnostics: readonly Diagnostic[] = [];
56
76
  let unregisteredAgents: readonly GeneratedAgent[] = [];
57
77
  let modeActive = false;
78
+ /** This session's own model filter, restored from the active branch. It wins over a saved one. */
79
+ let sessionFilter: ModelFilter | undefined;
80
+ let savedFilter: AdapterConfig["modelFilter"];
81
+ /** Why the config files could not be read, so the saved filter is unknown. */
82
+ let configError: string | undefined;
58
83
  const commandNamespace = startupNamespace();
59
84
 
60
85
  pi.on("session_start", async (_event, ctx) => {
@@ -62,9 +87,27 @@ export default function (pi: ExtensionAPI) {
62
87
  state = undefined;
63
88
  startupError = undefined;
64
89
  modeActive = modeFromEntries(ctx.sessionManager.getBranch() as never[]);
90
+ sessionFilter = sessionFilterFromEntries(ctx.sessionManager.getBranch() as never[]);
91
+ savedFilter = undefined;
92
+ configError = undefined;
65
93
 
94
+ let config: AdapterConfig;
66
95
  try {
67
- state = await bringUpUpstream(loadConfig(ctx), ctx);
96
+ config = loadConfig(ctx);
97
+ } catch (error) {
98
+ startupError = error as Error;
99
+ try {
100
+ savedFilter = loadConfig(ctx, true).modelFilter;
101
+ } catch (filterError) {
102
+ configError = (filterError as Error).message;
103
+ }
104
+ if (ctx.hasUI) ctx.ui.notify(`pstack adapter: ${(error as Error).message}`, "error");
105
+ return;
106
+ }
107
+ savedFilter = config.modelFilter;
108
+
109
+ try {
110
+ state = await bringUpUpstream(config, ctx);
68
111
  } catch (error) {
69
112
  startupError = error as Error;
70
113
  if (ctx.hasUI) ctx.ui.notify(`pstack adapter: ${(error as Error).message}`, "error");
@@ -101,6 +144,29 @@ export default function (pi: ExtensionAPI) {
101
144
  }
102
145
  });
103
146
 
147
+ // `/tree` moves the active branch, and with it the session filter that branch recorded.
148
+ pi.on("session_tree", async (_event, ctx) => {
149
+ sessionFilter = sessionFilterFromEntries(ctx.sessionManager.getBranch() as never[]);
150
+ });
151
+
152
+ // The model filter applies to every delegation in this session, pstack or not, because a
153
+ // delegation tool call names no pstack role. A blocked call fails; nothing retries it.
154
+ pi.on("tool_call", async (event, ctx) => {
155
+ if (event.toolName !== T3_DELEGATE_TOOL && event.toolName !== SUBAGENT_TOOL) return;
156
+ if (sessionFilter === undefined && configError !== undefined) {
157
+ return {
158
+ block: true,
159
+ reason: `Blocked by the pstack adapter: its config could not be read, so it cannot tell whether a saved model filter applies. ${configError} Fix the config and reload, or ask the user whether to set an explicit session filter with \`set_filter\`.`,
160
+ };
161
+ }
162
+ const decision = gateDelegation(event.toolName, event.input, {
163
+ filter: filterOf(activeFilter(sessionFilter, savedFilter)),
164
+ parentModel: parentModelOf(ctx),
165
+ resolveModel: (model) => resolveRegistryModel(ctx, model),
166
+ });
167
+ if (decision.kind === "block") return { block: true, reason: decision.reason };
168
+ });
169
+
104
170
  pi.on("resources_discover", async () => {
105
171
  if (!state) return {};
106
172
  return { skillPaths: [state.generation.skillsDir] };
@@ -126,7 +192,7 @@ export default function (pi: ExtensionAPI) {
126
192
  name: "pstack_adapter",
127
193
  label: "pstack adapter",
128
194
  description:
129
- "Inspect and configure the pstack adapter. Use `status` for the full report, `check` before starting a pstack workflow, `models` to list real Pi model ids, `set_model` to assign a pstack role, and `set_source` to select another upstream revision.",
195
+ "Inspect and configure the pstack adapter. Use `status` for the full report, `check` before starting a pstack workflow, `models` to list real Pi model ids, `set_model` to assign a pstack role or a pool of models, `set_filter` to restrict which models delegation may use, and `set_source` to select another upstream revision.",
130
196
  parameters: Type.Object({
131
197
  action: Type.Union(
132
198
  [
@@ -134,13 +200,19 @@ export default function (pi: ExtensionAPI) {
134
200
  Type.Literal("check"),
135
201
  Type.Literal("models"),
136
202
  Type.Literal("set_model"),
203
+ Type.Literal("set_filter"),
137
204
  Type.Literal("set_source"),
138
205
  ],
139
206
  { description: "Operation to run" },
140
207
  ),
141
208
  skill: Type.Optional(Type.String({ description: "Skill name for `check`, with or without the namespace prefix" })),
142
209
  role: Type.Optional(Type.String({ description: "pstack model role for `set_model`" })),
143
- model: Type.Optional(Type.String({ description: "`provider/model` id, or `inherit` to clear the role" })),
210
+ model: Type.Optional(
211
+ Type.Union([Type.String(), Type.Array(Type.String())], {
212
+ description:
213
+ "`provider/model` id, or `inherit` to clear the role. A list of ids gives an ordinary role a pool: each delegation picks one. For a panel role such as `arena-runners`, a string of entries separated by commas, one seat each.",
214
+ }),
215
+ ),
144
216
  thinking: Type.Optional(
145
217
  Type.String({
146
218
  description: `Thinking level for \`set_model\`: ${THINKING_LEVELS.join(", ")}. Omit to pass no level, which also clears a level set earlier.`,
@@ -149,9 +221,16 @@ export default function (pi: ExtensionAPI) {
149
221
  repo: Type.Optional(Type.String({ description: "Upstream repository URL for `set_source`" })),
150
222
  ref: Type.Optional(Type.String({ description: "Branch, tag, or commit for `set_source`" })),
151
223
  localPath: Type.Optional(Type.String({ description: "Local pstack checkout for `set_source`" })),
224
+ include: Type.Optional(
225
+ Type.Array(Type.String(), {
226
+ description: "`set_filter`: patterns a delegation model must match, such as `gpt-*`. `*` is the only wildcard; a pattern with `/` matches `provider/model`.",
227
+ }),
228
+ ),
229
+ exclude: Type.Optional(Type.Array(Type.String(), { description: "`set_filter`: patterns no delegation model may match, such as `claude-*`. Exclude wins." })),
230
+ clear: Type.Optional(Type.Boolean({ description: "`set_filter`: remove the filter at `scope` instead of setting one." })),
152
231
  scope: Type.Optional(
153
- Type.Union([Type.Literal("user"), Type.Literal("project")], {
154
- description: "Config file to write. Defaults to user.",
232
+ Type.Union([Type.Literal("session"), Type.Literal("user"), Type.Literal("project")], {
233
+ description: "`set_filter` defaults to session, this session only. `set_model` and `set_source` write a config file: user (default) or project.",
155
234
  }),
156
235
  ),
157
236
  }),
@@ -205,15 +284,20 @@ export default function (pi: ExtensionAPI) {
205
284
  action: string;
206
285
  skill?: string;
207
286
  role?: string;
208
- model?: string;
287
+ model?: string | string[];
209
288
  thinking?: string;
210
289
  repo?: string;
211
290
  ref?: string;
212
291
  localPath?: string;
213
- scope?: "user" | "project";
292
+ include?: string[];
293
+ exclude?: string[];
294
+ clear?: boolean;
295
+ scope?: "session" | "user" | "project";
214
296
  },
215
297
  ctx: ExtensionContext,
216
298
  ): Promise<string> {
299
+ // A restriction must be settable even when the upstream checkout did not resolve.
300
+ if (params.action === "set_filter") return setFilter(params, params.scope ?? "session", ctx);
217
301
  if (startupError) return `The pstack adapter did not start: ${startupError.message}`;
218
302
  if (!state) return "The pstack adapter has not resolved its upstream checkout yet.";
219
303
 
@@ -233,6 +317,8 @@ export default function (pi: ExtensionAPI) {
233
317
  modeActive,
234
318
  cacheRoot: state.cacheRoot,
235
319
  bootstrapped: state.bootstrapped,
320
+ filter: activeFilter(sessionFilter, savedFilter),
321
+ parentModel: parentModelOf(ctx),
236
322
  runtimeDiagnostics: [
237
323
  ...agentDiagnostics,
238
324
  // Judged now, because T3's tools can connect after the agents were registered.
@@ -245,10 +331,14 @@ export default function (pi: ExtensionAPI) {
245
331
  return checkSkill(params.skill ?? "", ctx);
246
332
  case "models":
247
333
  return renderModels(ctx);
248
- case "set_model":
334
+ case "set_model": {
335
+ if (params.scope === "session") return SESSION_SCOPE_ONLY_FILTER;
249
336
  return setModel(params.role, params.model, params.thinking, params.scope ?? "user", ctx);
250
- case "set_source":
337
+ }
338
+ case "set_source": {
339
+ if (params.scope === "session") return SESSION_SCOPE_ONLY_FILTER;
251
340
  return setSource(params, params.scope ?? "user", ctx);
341
+ }
252
342
  default:
253
343
  return `Unknown action ${params.action}.`;
254
344
  }
@@ -275,11 +365,16 @@ export default function (pi: ExtensionAPI) {
275
365
  const shell: ShellState = { ...resolved, runError: executables.shellFailure };
276
366
  const shellProblems = shellDiagnostics(shell).map((diagnostic) => `${diagnostic.message} ${diagnostic.action ?? ""}`.trim());
277
367
  const current = capabilities();
368
+ const panels = Object.hasOwn(SKILL_PANELS, skill.upstreamName) ? SKILL_PANELS[skill.upstreamName] : undefined;
369
+ const origin = activeFilter(sessionFilter, savedFilter);
370
+ const parentModel = parentModelOf(ctx);
278
371
  return renderCheck(
279
372
  skill,
280
373
  entryFor(skill.upstreamName, skill.generatedName.slice(prefix.length)),
281
374
  checkPrerequisites(skill.capabilities, current, skill.executables, executables.has, shellProblems),
282
375
  current.find((capability) => capability.id === "delegation"),
376
+ panels ? renderPanels(state.config, panels, { filter: filterOf(origin), parentModel }) : undefined,
377
+ renderModelPolicy(state.config, origin, parentModel),
283
378
  );
284
379
  }
285
380
 
@@ -293,17 +388,24 @@ export default function (pi: ExtensionAPI) {
293
388
 
294
389
  function setModel(
295
390
  role: string | undefined,
296
- model: string | undefined,
391
+ model: string | string[] | undefined,
297
392
  thinking: string | undefined,
298
393
  scope: "user" | "project",
299
394
  ctx: ExtensionContext,
300
395
  ): string {
301
396
  if (!role) return "set_model needs a `role`.";
302
- if (!model) return "set_model needs a `model`, or `inherit` to clear the role.";
397
+ if (!model || model.length === 0) return "set_model needs a `model`, or `inherit` to clear the role.";
303
398
  if (!isValidRole(role)) return invalidRoleMessage(role);
304
399
  const available = ctx.modelRegistry
305
400
  .getAvailable()
306
401
  .map((candidate) => ({ id: `${candidate.provider}/${candidate.id}`, reasoning: candidate.reasoning }));
402
+ if (Array.isArray(model)) {
403
+ if (isPanelRole(role)) {
404
+ return `Panel \`${role}\` takes one string of entries separated by commas, one seat each, not a list. A list is a pool, which only ordinary roles have.`;
405
+ }
406
+ return setPool(role, model, thinking, available, scope, ctx);
407
+ }
408
+ if (isPanelRole(role)) return setPanel(role, model, thinking, available, scope, ctx);
307
409
  const plan = planModelAssignment(model, thinking, available);
308
410
  if (plan.kind === "rejected") return plan.message;
309
411
  const path = configPath(scope, ctx);
@@ -312,10 +414,117 @@ export default function (pi: ExtensionAPI) {
312
414
  if (plan.kind === "inherit") delete models[role];
313
415
  else models[role] = joinModelSpec(plan.spec);
314
416
  updateConfigFile(path, { models });
315
- if (plan.kind === "inherit") return `Role \`${role}\` now inherits the parent session model and thinking. Written to ${path}.`;
417
+ const note = refreshModels(scope, ctx);
418
+ if (plan.kind === "inherit") return `Role \`${role}\` now inherits the parent session model and thinking. Written to ${path}.${note}`;
316
419
  return plan.spec.thinking === undefined
317
- ? `Role \`${role}\` now uses \`${plan.spec.model}\` at the model's default thinking. Written to ${path}.`
318
- : `Role \`${role}\` now uses \`${plan.spec.model}\` with thinking \`${plan.spec.thinking}\`. Written to ${path}.`;
420
+ ? `Role \`${role}\` now uses \`${plan.spec.model}\` at the model's default thinking. Written to ${path}.${note}`
421
+ : `Role \`${role}\` now uses \`${plan.spec.model}\` with thinking \`${plan.spec.thinking}\`. Written to ${path}.${note}`;
422
+ }
423
+
424
+ /**
425
+ * Status and `check` read roles from `state`, so a written role takes effect now. Only `models`
426
+ * moves: the source and generation stay as they were until the next start. Returns a note for
427
+ * the reply when the session view does not match what was written.
428
+ */
429
+ function refreshModels(scope: "user" | "project", ctx: ExtensionContext): string {
430
+ const untrusted =
431
+ scope === "project" && !ctx.isProjectTrusted()
432
+ ? " This project is not trusted, so Pi ignores the file until you trust it, and the status report still shows the earlier value."
433
+ : "";
434
+ try {
435
+ const fresh = loadConfig(ctx);
436
+ savedFilter = fresh.modelFilter;
437
+ configError = undefined;
438
+ if (state) state = { ...state, config: { ...state.config, models: fresh.models, modelFilter: fresh.modelFilter } };
439
+ } catch (error) {
440
+ try {
441
+ savedFilter = loadConfig(ctx, true).modelFilter;
442
+ configError = undefined;
443
+ } catch (filterError) {
444
+ configError = (filterError as Error).message;
445
+ }
446
+ return `${untrusted} The write succeeded, but the model role view was not refreshed: ${(error as Error).message}`;
447
+ }
448
+ return untrusted;
449
+ }
450
+
451
+ function setPool(
452
+ role: string,
453
+ entries: readonly string[],
454
+ thinking: string | undefined,
455
+ available: readonly { id: string; reasoning?: boolean }[],
456
+ scope: "user" | "project",
457
+ ctx: ExtensionContext,
458
+ ): string {
459
+ const plan = planPoolAssignment(entries, thinking, available);
460
+ if (plan.kind === "rejected") return plan.message;
461
+ const path = configPath(scope, ctx);
462
+ const stored = readConfigFile(path)?.values.models;
463
+ const models = typeof stored === "object" && stored !== null && !Array.isArray(stored) ? { ...stored } : {};
464
+ models[role] = plan.value;
465
+ updateConfigFile(path, { models });
466
+ const note = refreshModels(scope, ctx);
467
+ const listed = (typeof plan.value === "string" ? [plan.value] : plan.value).map((entry) => `\`${entry}\``).join(", ");
468
+ return typeof plan.value === "string"
469
+ ? `Role \`${role}\` now uses ${listed}. A pool of one is written as a plain model. Written to ${path}.${note}`
470
+ : `Role \`${role}\` now has a pool of ${plan.value.length} models: ${listed}. Each delegation picks one model the filter allows. Written to ${path}.${note}`;
471
+ }
472
+
473
+ function setFilter(
474
+ params: { include?: string[]; exclude?: string[]; clear?: boolean },
475
+ scope: "session" | "user" | "project",
476
+ ctx: ExtensionContext,
477
+ ): string {
478
+ const setting = params.include !== undefined || params.exclude !== undefined;
479
+ if (params.clear && setting) return "set_filter takes either `clear: true` or `include` and `exclude`, not both.";
480
+ if (!params.clear && !setting) {
481
+ return "set_filter needs `include`, `exclude`, or `clear: true`. Empty lists set an unrestricted filter at that scope.";
482
+ }
483
+ let filter: ModelFilter | undefined;
484
+ if (setting) {
485
+ const raw: Record<string, unknown> = {};
486
+ if (params.include !== undefined) raw.include = params.include;
487
+ if (params.exclude !== undefined) raw.exclude = params.exclude;
488
+ const parsed = parseModelFilter(raw);
489
+ if ("problem" in parsed) return parsed.problem;
490
+ filter = parsed.filter;
491
+ }
492
+ if (scope === "session") {
493
+ sessionFilter = filter;
494
+ pi.appendEntry(FILTER_ENTRY_TYPE, filter ? { filter } : { cleared: true });
495
+ const origin = activeFilter(sessionFilter, savedFilter);
496
+ return `${filter ? "This session's model filter is set" : "This session's model filter is cleared"}. Filter in force: ${describeOrigin(origin)}. It applies to every \`delegate_task\` and \`subagent\` call in this session, and is not saved to a config file.`;
497
+ }
498
+ const path = configPath(scope, ctx);
499
+ updateConfigFile(path, { modelFilter: filter });
500
+ const note = refreshModels(scope, ctx);
501
+ const origin = activeFilter(sessionFilter, savedFilter);
502
+ const shadowed =
503
+ sessionFilter !== undefined
504
+ ? " This session's own filter still wins over the saved one. Clear it with `set_filter`, `clear: true`, and `scope: \"session\"` to use the saved filter here."
505
+ : "";
506
+ return `${filter ? "Saved model filter" : "Removed the saved model filter"} in ${path}.${note}${shadowed} Filter in force: ${describeOrigin(origin)}.`;
507
+ }
508
+
509
+ function setPanel(
510
+ role: string,
511
+ model: string,
512
+ thinking: string | undefined,
513
+ available: readonly { id: string; reasoning?: boolean }[],
514
+ scope: "user" | "project",
515
+ ctx: ExtensionContext,
516
+ ): string {
517
+ const plan = planPanelAssignment(model, thinking, available);
518
+ if (plan.kind === "rejected") return plan.message;
519
+ const path = configPath(scope, ctx);
520
+ const stored = readConfigFile(path)?.values.models;
521
+ const models = typeof stored === "object" && stored !== null && !Array.isArray(stored) ? { ...stored } : {};
522
+ if (plan.kind === "inherit") delete models[role];
523
+ else models[role] = plan.value;
524
+ updateConfigFile(path, { models });
525
+ const note = refreshModels(scope, ctx);
526
+ if (plan.kind === "inherit") return `Panel \`${role}\` is unset again, so it uses \`judgment\`, then \`general\`. Written to ${path}.${note}`;
527
+ return `Panel \`${role}\` now runs one seat each on ${plan.value.split(" ").map((entry) => `\`${entry}\``).join(", ")}. Written to ${path}.${note}`;
319
528
  }
320
529
 
321
530
  async function setSource(
@@ -393,7 +602,24 @@ function startupNamespace(): string {
393
602
  }
394
603
  }
395
604
 
396
- function loadConfig(ctx: ExtensionContext): AdapterConfig {
605
+ const SESSION_SCOPE_ONLY_FILTER =
606
+ "Only `set_filter` takes `scope: \"session\"`. `set_model` and `set_source` write a config file, so pass `scope: \"user\"` or `scope: \"project\"`.";
607
+
608
+ function parentModelOf(ctx: ExtensionContext): string | undefined {
609
+ return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
610
+ }
611
+
612
+ /** A requested model as its registry `provider/id`. A bare id counts only when exactly one model has it. */
613
+ function resolveRegistryModel(ctx: ExtensionContext, model: string): string | undefined {
614
+ const ids = ctx.modelRegistry.getAvailable().map((candidate) => ({ full: `${candidate.provider}/${candidate.id}`, id: candidate.id }));
615
+ const exact = ids.find((candidate) => candidate.full === model);
616
+ if (exact) return exact.full;
617
+ if (model.includes("/")) return undefined;
618
+ const bare = ids.filter((candidate) => candidate.id === model);
619
+ return bare.length === 1 ? bare[0]?.full : undefined;
620
+ }
621
+
622
+ function loadConfig(ctx: ExtensionContext, filterOnly = false): AdapterConfig {
397
623
  const layers: ConfigLayer[] = [];
398
624
  const global = readConfigFile(userConfigPath());
399
625
  if (global) layers.push(global);
@@ -403,8 +629,8 @@ function loadConfig(ctx: ExtensionContext): AdapterConfig {
403
629
  // without desynchronizing `/<ns>-status` from the generated `<ns>-*` skills.
404
630
  if (project) layers.push({ ...project, values: { ...project.values, namespace: undefined } });
405
631
  }
406
- layers.push(environmentLayer(process.env));
407
- return mergeConfig(layers);
632
+ layers.push(environmentLayer(filterOnly ? { ...process.env, PISTACK_MODELS: undefined } : process.env));
633
+ return mergeConfig(filterOnly ? layers.map((layer) => ({ ...layer, values: { modelFilter: layer.values.modelFilter } })) : layers);
408
634
  }
409
635
 
410
636
  function configPath(scope: "user" | "project", ctx: ExtensionContext): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pstack-adapter",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Run Cursor's official pstack plugin inside Pi from an unmodified upstream checkout",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/certified.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  export const CERTIFIED = {
6
6
  repo: "https://github.com/cursor/plugins",
7
7
  ref: "main",
8
- commit: "d457567332aa6989e753ea289f30ea2bfbc80bc0",
8
+ commit: "c742dd61706fbacc1b95f1a20b7b988cf096ce6c",
9
9
  pluginPath: "pstack",
10
- pstackVersion: "0.15.28",
11
- skillCount: 56,
10
+ pstackVersion: "0.15.36",
11
+ skillCount: 58,
12
12
  agentCount: 2,
13
13
  } as const;