pipe-kan 0.18.0 → 0.19.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/pipe-kan.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/server.ts
4
- import { readFileSync as readFileSync3, writeSync } from "node:fs";
4
+ import { readFileSync as readFileSync4, writeSync } from "node:fs";
5
5
  import { createServer } from "node:http";
6
6
  import { tmpdir as tmpdir2 } from "node:os";
7
- import { join as join7 } from "node:path";
7
+ import { join as join8 } from "node:path";
8
8
 
9
9
  // src/board.ts
10
10
  function formatDueDate(value) {
@@ -904,25 +904,148 @@ function handleAppApi(req, res, app) {
904
904
  return false;
905
905
  }
906
906
 
907
+ // src/agent-context.ts
908
+ function boardViewContext(view) {
909
+ return [
910
+ { type: "text", text: formatBoardView({ ...view, scope: view.scope ?? "view" }) },
911
+ {
912
+ type: "resource",
913
+ resource: {
914
+ uri: "pipe-kan://board",
915
+ mimeType: "application/json",
916
+ text: JSON.stringify(view)
917
+ }
918
+ }
919
+ ];
920
+ }
921
+ function pipedBoardContext(board) {
922
+ return boardViewContext({
923
+ scope: "pipe",
924
+ kind: "stories",
925
+ selectedEpic: null,
926
+ search: "",
927
+ filter: {},
928
+ sort: "payload",
929
+ hide: [],
930
+ columns: board.columns,
931
+ epics: board.epics
932
+ });
933
+ }
934
+ function hasAttachedBoard(context = []) {
935
+ return context.some((block) => block.type === "resource" && block.resource.uri === "pipe-kan://board");
936
+ }
937
+ function withPipedBoardContext(board, clientContext = []) {
938
+ if (hasAttachedBoard(clientContext))
939
+ return clientContext;
940
+ return [...pipedBoardContext(board), ...clientContext];
941
+ }
942
+ function formatBoardView(view) {
943
+ const opener = view.kind === "epics" ? "All epics" : "All stories";
944
+ const scope = view.selectedEpic ? `${opener}, children of ${view.selectedEpic}` : opener;
945
+ const lines = [
946
+ view.scope === "pipe" ? "The user is working with piped Jira issues (the full payload). Answer from these Jira issues. This is not GitHub and not the local source repo." : "The user attached the current Jira Kanban board (visible cards only). Answer from these Jira issues. This is not GitHub and not the local source repo.",
947
+ `Board: ${scope}`
948
+ ];
949
+ if (view.search.trim())
950
+ lines.push(`Search: ${view.search.trim()}`);
951
+ if (Object.values(view.filter).some((values) => values.length)) {
952
+ lines.push(`Filter: ${JSON.stringify(view.filter)}`);
953
+ }
954
+ lines.push(`Sort: ${view.sort}`);
955
+ if (view.hide.length)
956
+ lines.push(`Hidden columns: ${view.hide.join(", ")}`);
957
+ lines.push("", "Columns:");
958
+ if (!view.columns.length) {
959
+ lines.push("(empty)");
960
+ } else {
961
+ for (const column of view.columns) {
962
+ lines.push(`## ${column.title}`);
963
+ if (!column.cards.length) {
964
+ lines.push("(no cards)");
965
+ continue;
966
+ }
967
+ for (const card of column.cards) {
968
+ lines.push(`- ${formatCard(card)}`);
969
+ }
970
+ }
971
+ }
972
+ if (view.epics.length) {
973
+ lines.push("", "Epics:");
974
+ for (const epic of view.epics) {
975
+ const status = epic.status ? ` (${epic.status})` : "";
976
+ lines.push(`- ${epic.key} ${epic.summary}${status}`);
977
+ }
978
+ }
979
+ return lines.join(`
980
+ `);
981
+ }
982
+ function formatCard(card) {
983
+ const bits = [card.key, card.summary];
984
+ if (card.epic)
985
+ bits.push(`[epic ${card.epic}]`);
986
+ const extras = [card.priority, card.assignee, card.dueDate, card.labels?.join(", ")].filter((value) => Boolean(value));
987
+ if (extras.length)
988
+ bits.push(`(${extras.join(", ")})`);
989
+ return bits.join(" ");
990
+ }
991
+
907
992
  // src/server/agent/config.ts
908
- import { existsSync as existsSync2, readFileSync } from "node:fs";
993
+ import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "node:fs";
909
994
  import { homedir } from "node:os";
910
- import { join as join2 } from "node:path";
995
+ import { delimiter as delimiter2, dirname, join as join2 } from "node:path";
911
996
  var DEFAULT_CONFIG_PATH = join2(homedir(), ".config", "pipe-kan", "agent.json");
997
+ var FALLBACK_MODELS = {
998
+ cursor: [
999
+ { id: "composer-2", name: "Composer 2" },
1000
+ { id: "composer-2-fast", name: "Composer 2 Fast" },
1001
+ { id: "claude-sonnet-4", name: "Claude Sonnet 4" },
1002
+ { id: "gpt-4.1", name: "GPT-4.1" }
1003
+ ],
1004
+ devin: [
1005
+ { id: "swe-1-6-slow", name: "SWE-1.6 (slow)", description: "Free slower tier" },
1006
+ { id: "swe-1-6-fast", name: "SWE-1.6 Fast" },
1007
+ { id: "swe-1-7", name: "SWE-1.7" },
1008
+ { id: "swe", name: "SWE (latest)" },
1009
+ { id: "adaptive", name: "Adaptive" },
1010
+ { id: "sonnet", name: "Sonnet" },
1011
+ { id: "opus", name: "Opus" },
1012
+ { id: "gpt", name: "GPT" }
1013
+ ]
1014
+ };
912
1015
  var DEFAULT_CONFIG = {
913
- defaultAgent: "cursor",
1016
+ defaultAgent: "devin",
1017
+ defaultSkill: null,
914
1018
  agents: {
915
1019
  cursor: {
916
1020
  command: "cursor-agent",
917
- args: ["acp"]
1021
+ args: ["acp"],
1022
+ model: "claude-sonnet-4"
918
1023
  },
919
1024
  devin: {
920
1025
  command: "devin",
921
- args: ["acp"]
1026
+ args: ["acp"],
1027
+ model: "swe-1-6-slow"
922
1028
  }
923
1029
  }
924
1030
  };
925
- function loadAgentConfig(path = DEFAULT_CONFIG_PATH) {
1031
+ function agentConfigPath() {
1032
+ return process.env.PIPE_KAN_AGENT_CONFIG ?? DEFAULT_CONFIG_PATH;
1033
+ }
1034
+ function commandOnPath(command, env = process.env) {
1035
+ if (!command)
1036
+ return false;
1037
+ if (command.includes("/") || command.includes("\\"))
1038
+ return existsSync2(command);
1039
+ const pathVar = env.PATH ?? "";
1040
+ for (const dir of pathVar.split(delimiter2)) {
1041
+ if (!dir)
1042
+ continue;
1043
+ if (existsSync2(join2(dir, command)))
1044
+ return true;
1045
+ }
1046
+ return false;
1047
+ }
1048
+ function loadAgentConfig(path = agentConfigPath()) {
926
1049
  if (!existsSync2(path))
927
1050
  return structuredClone(DEFAULT_CONFIG);
928
1051
  try {
@@ -932,16 +1055,43 @@ function loadAgentConfig(path = DEFAULT_CONFIG_PATH) {
932
1055
  return structuredClone(DEFAULT_CONFIG);
933
1056
  }
934
1057
  }
1058
+ function saveAgentConfig(patch, path = agentConfigPath()) {
1059
+ const current = loadAgentConfig(path);
1060
+ const next = {
1061
+ defaultAgent: patch.defaultAgent ?? current.defaultAgent,
1062
+ defaultSkill: patch.defaultSkill !== undefined ? patch.defaultSkill : current.defaultSkill,
1063
+ agents: { ...current.agents }
1064
+ };
1065
+ if (patch.agents) {
1066
+ for (const [id, config] of Object.entries(patch.agents)) {
1067
+ next.agents[id] = { ...next.agents[id], ...config };
1068
+ }
1069
+ }
1070
+ ensureAgentConfigDir(path);
1071
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}
1072
+ `);
1073
+ return next;
1074
+ }
935
1075
  function mergeConfig(raw) {
1076
+ const agents = { ...DEFAULT_CONFIG.agents };
1077
+ for (const [id, config] of Object.entries({ ...DEFAULT_CONFIG.agents, ...raw.agents })) {
1078
+ agents[id] = { ...DEFAULT_CONFIG.agents[id], ...config };
1079
+ }
936
1080
  return {
937
1081
  defaultAgent: raw.defaultAgent ?? DEFAULT_CONFIG.defaultAgent,
938
- agents: { ...DEFAULT_CONFIG.agents, ...raw.agents }
1082
+ defaultSkill: raw.defaultSkill ?? DEFAULT_CONFIG.defaultSkill,
1083
+ agents
939
1084
  };
940
1085
  }
1086
+ function ensureAgentConfigDir(path = agentConfigPath()) {
1087
+ const dir = dirname(path);
1088
+ if (!existsSync2(dir))
1089
+ mkdirSync(dir, { recursive: true });
1090
+ }
941
1091
 
942
1092
  // src/server/agent/session.ts
943
1093
  import { spawn as spawn2 } from "node:child_process";
944
- import { mkdtempSync } from "node:fs";
1094
+ import { mkdtempSync, rmSync } from "node:fs";
945
1095
  import { tmpdir } from "node:os";
946
1096
  import { join as join3 } from "node:path";
947
1097
  import { Readable } from "node:stream";
@@ -10571,25 +10721,93 @@ var legacyClientNotificationMethods = new Set([
10571
10721
  CLIENT_METHODS.elicitation_complete
10572
10722
  ]);
10573
10723
 
10574
- // src/server/agent/session.ts
10575
- function createAcpBackend(id, label) {
10576
- return {
10577
- id,
10578
- label,
10579
- spawn(config) {
10580
- const session = new AcpSession(config);
10581
- return session.connect().then(() => session);
10724
+ // src/server/agent/models.ts
10725
+ function modelsFromConfigOptions(options) {
10726
+ if (!options?.length)
10727
+ return [];
10728
+ const model = options.find((option) => option.category === "model" || option.id === "model");
10729
+ if (!model || model.type !== "select")
10730
+ return [];
10731
+ return flattenSelectOptions(model.options);
10732
+ }
10733
+ function selectedModelFromConfigOptions(options) {
10734
+ const model = options?.find((option) => option.category === "model" || option.id === "model");
10735
+ if (!model || model.type !== "select")
10736
+ return null;
10737
+ return model.currentValue ?? null;
10738
+ }
10739
+ var MODEL_ALIASES = {
10740
+ "swe-1-6": ["swe-1-6-slow"],
10741
+ "swe-1.6": ["swe-1-6-slow"],
10742
+ "swe-1.6-slow": ["swe-1-6-slow"],
10743
+ "swe-1-6-slow": ["swe-1-6"]
10744
+ };
10745
+ function resolveRequestedModel(requested, live) {
10746
+ if (!requested)
10747
+ return null;
10748
+ if (!live.length)
10749
+ return requested;
10750
+ if (live.some((model) => model.id === requested))
10751
+ return requested;
10752
+ for (const alias of MODEL_ALIASES[requested] ?? []) {
10753
+ const hit = live.find((model) => model.id === alias);
10754
+ if (hit)
10755
+ return hit.id;
10756
+ }
10757
+ const lower = requested.toLowerCase();
10758
+ return live.find((model) => model.name.toLowerCase() === lower)?.id ?? null;
10759
+ }
10760
+ function errorText(err) {
10761
+ if (err instanceof Error) {
10762
+ const extra = err.data;
10763
+ return extra ? `${err.message} ${JSON.stringify(extra)}` : err.message;
10764
+ }
10765
+ return String(err);
10766
+ }
10767
+ function parseAvailableModelsFromError(err) {
10768
+ const text = errorText(err);
10769
+ const match = text.match(/Available models:\s*([^\n"}]+)/i);
10770
+ if (!match)
10771
+ return [];
10772
+ return match[1].split(",").map((id) => id.trim()).filter(Boolean);
10773
+ }
10774
+ function flattenSelectOptions(options) {
10775
+ if (!Array.isArray(options))
10776
+ return [];
10777
+ const models = [];
10778
+ for (const item of options) {
10779
+ if (!item || typeof item !== "object")
10780
+ continue;
10781
+ if ("group" in item && Array.isArray(item.options)) {
10782
+ models.push(...flattenSelectOptions(item.options));
10783
+ continue;
10582
10784
  }
10583
- };
10785
+ if ("value" in item && "name" in item) {
10786
+ const option = item;
10787
+ models.push({
10788
+ id: option.value,
10789
+ name: option.name,
10790
+ ...option.description ? { description: option.description } : {}
10791
+ });
10792
+ }
10793
+ }
10794
+ return models;
10584
10795
  }
10585
10796
 
10797
+ // src/server/agent/session.ts
10798
+ var CONNECT_TIMEOUT_MS = 15000;
10799
+ function createAcpSession(config) {
10800
+ return new AcpSession(config);
10801
+ }
10586
10802
  class AcpSession {
10587
10803
  config;
10588
10804
  id;
10589
10805
  proc = null;
10590
10806
  activeSession = null;
10807
+ agentCtx = null;
10808
+ configOptions = [];
10591
10809
  eventsQueue = [];
10592
- eventResolvers = [];
10810
+ listeners = new Set;
10593
10811
  pendingPermissionResolvers = new Map;
10594
10812
  closed = false;
10595
10813
  lifetime = null;
@@ -10597,30 +10815,51 @@ class AcpSession {
10597
10815
  textBuffer = "";
10598
10816
  detectToolCall = null;
10599
10817
  toolCalls = new Map;
10818
+ shouldAutoApproveTool = null;
10819
+ executeTool = null;
10820
+ stderr = "";
10821
+ selectedModelId = null;
10822
+ workspace = mkdtempSync(join3(tmpdir(), "pipe-kan-agent-"));
10600
10823
  constructor(config) {
10601
10824
  this.config = config;
10602
10825
  this.id = crypto.randomUUID();
10826
+ this.selectedModelId = config.model ?? null;
10827
+ this.ready.promise.catch(() => {
10828
+ return;
10829
+ });
10603
10830
  }
10604
10831
  async prompt(text, context) {
10605
10832
  if (!this.activeSession)
10606
10833
  throw new Error("ACP session not connected");
10607
- const blocks = (context ?? []).map(toContentBlock);
10608
- blocks.push({ type: "text", text });
10609
- await this.activeSession.prompt(blocks);
10834
+ const resources = (context ?? []).filter((block) => block.type === "resource").map(toContentBlock);
10835
+ const contextText = (context ?? []).filter((block) => block.type === "text").map((block) => block.text).filter(Boolean).join(`
10836
+
10837
+ `);
10838
+ const prompt = contextText ? `${contextText}
10839
+
10840
+ ${text}` : text;
10841
+ await this.activeSession.prompt([...resources, { type: "text", text: prompt }]);
10610
10842
  }
10611
10843
  setToolParser(parser) {
10612
10844
  this.detectToolCall = parser;
10613
10845
  }
10846
+ setToolExecutor(shouldAutoApprove, execute) {
10847
+ this.shouldAutoApproveTool = shouldAutoApprove;
10848
+ this.executeTool = execute;
10849
+ }
10614
10850
  pendingToolCalls() {
10615
10851
  return this.toolCalls;
10616
10852
  }
10617
- resolveToolCall(requestId, resultText) {
10853
+ resolveToolCall(requestId, resultText, rawResult) {
10618
10854
  const call = this.toolCalls.get(requestId);
10619
10855
  if (!call)
10620
10856
  return;
10621
10857
  this.toolCalls.delete(requestId);
10858
+ this.push({ type: "tool_result", requestId, name: call.name, result: rawResult ?? resultText });
10622
10859
  this.prompt(`Tool result for ${call.name}(${JSON.stringify(call.args)}):
10623
- ${resultText}`);
10860
+ ${resultText}`).catch(() => {
10861
+ return;
10862
+ });
10624
10863
  }
10625
10864
  approve(requestId, decision) {
10626
10865
  const entry = this.pendingPermissionResolvers.get(requestId);
@@ -10631,21 +10870,78 @@ ${resultText}`);
10631
10870
  entry.resolve(outcome);
10632
10871
  this.pendingPermissionResolvers.delete(requestId);
10633
10872
  }
10873
+ models() {
10874
+ const live = modelsFromConfigOptions(this.configOptions);
10875
+ return live.length ? live : [];
10876
+ }
10877
+ selectedModel() {
10878
+ return selectedModelFromConfigOptions(this.configOptions) ?? this.selectedModelId;
10879
+ }
10880
+ async setModel(id) {
10881
+ const live = this.models();
10882
+ let resolved = resolveRequestedModel(id, live);
10883
+ if (!resolved && live.length)
10884
+ return;
10885
+ resolved = resolved ?? id;
10886
+ this.selectedModelId = resolved;
10887
+ this.config.model = resolved;
10888
+ const option = this.configOptions.find((item) => item.category === "model" || item.id === "model");
10889
+ if (!option || option.type !== "select" || !this.agentCtx || !this.activeSession)
10890
+ return;
10891
+ try {
10892
+ const result = await this.agentCtx.request(methods.agent.session.setConfigOption, {
10893
+ sessionId: this.activeSession.sessionId,
10894
+ configId: option.id,
10895
+ value: resolved
10896
+ });
10897
+ if (result.configOptions)
10898
+ this.configOptions = result.configOptions;
10899
+ this.selectedModelId = this.selectedModel();
10900
+ this.push({ type: "config", models: this.models(), selectedModel: this.selectedModel() });
10901
+ } catch (err) {
10902
+ const available = parseAvailableModelsFromError(err).map((modelId) => ({ id: modelId, name: modelId }));
10903
+ const retry = resolveRequestedModel(id, available) ?? resolveRequestedModel(resolved, available);
10904
+ if (retry && retry !== resolved) {
10905
+ await this.setModel(retry);
10906
+ return;
10907
+ }
10908
+ this.selectedModelId = selectedModelFromConfigOptions(this.configOptions);
10909
+ throw new Error(errorText(err));
10910
+ }
10911
+ }
10912
+ subscribe(listener) {
10913
+ this.listeners.add(listener);
10914
+ if (this.eventsQueue.length) {
10915
+ const queued = this.eventsQueue.splice(0);
10916
+ for (const event of queued)
10917
+ listener(event);
10918
+ }
10919
+ return () => {
10920
+ this.listeners.delete(listener);
10921
+ };
10922
+ }
10634
10923
  cancel() {
10635
10924
  this.proc?.kill("SIGTERM");
10636
10925
  }
10637
10926
  async* [Symbol.asyncIterator]() {
10638
- while (!this.closed || this.eventsQueue.length) {
10639
- if (this.eventsQueue.length) {
10640
- yield this.eventsQueue.shift();
10641
- } else {
10642
- const { promise, resolve } = Promise.withResolvers();
10643
- this.eventResolvers.push(resolve);
10644
- const result = await promise;
10645
- if (result.done)
10646
- return;
10647
- yield result.value;
10927
+ const queue = [];
10928
+ let wake = null;
10929
+ const unsub = this.subscribe((event) => {
10930
+ queue.push(event);
10931
+ wake?.();
10932
+ });
10933
+ try {
10934
+ while (!this.closed || queue.length) {
10935
+ if (!queue.length) {
10936
+ await new Promise((resolve) => {
10937
+ wake = resolve;
10938
+ });
10939
+ }
10940
+ while (queue.length)
10941
+ yield queue.shift();
10648
10942
  }
10943
+ } finally {
10944
+ unsub();
10649
10945
  }
10650
10946
  }
10651
10947
  events() {
@@ -10653,47 +10949,83 @@ ${resultText}`);
10653
10949
  }
10654
10950
  async close() {
10655
10951
  this.closed = true;
10952
+ this.ready.reject(new Error("ACP session closed"));
10656
10953
  this.activeSession?.dispose();
10657
10954
  this.lifetime?.resolve();
10658
10955
  this.proc?.kill("SIGTERM");
10659
10956
  this.flushQueue();
10660
10957
  this.proc = null;
10958
+ rmSync(this.workspace, { recursive: true, force: true });
10661
10959
  }
10662
10960
  async connect() {
10663
- const cwd = mkdtempSync(join3(tmpdir(), "pipe-kan-acp-"));
10961
+ if (this.closed)
10962
+ throw new Error("ACP session closed");
10963
+ const cwd = this.workspace;
10664
10964
  const cmd = this.config.command;
10665
10965
  const args = this.config.args ?? [];
10666
10966
  this.proc = spawn2(cmd, args, {
10667
- cwd,
10967
+ cwd: process.cwd(),
10668
10968
  env: { ...process.env, ...this.config.env ?? {} },
10669
10969
  stdio: ["pipe", "pipe", "pipe"]
10670
10970
  });
10971
+ if (this.closed) {
10972
+ this.proc.kill("SIGTERM");
10973
+ this.proc = null;
10974
+ throw new Error("ACP session closed");
10975
+ }
10671
10976
  if (!this.proc.stdout || !this.proc.stdin) {
10672
10977
  throw new Error(`Failed to spawn ACP agent: ${cmd}`);
10673
10978
  }
10979
+ this.proc.stderr?.on("data", (chunk) => {
10980
+ this.stderr += chunk.toString("utf8");
10981
+ });
10674
10982
  const stdout = Readable.toWeb(this.proc.stdout);
10675
10983
  const stream = ndJsonStream2(new WritableStream({
10676
10984
  write: (chunk) => this.writeStdin(chunk)
10677
10985
  }), stdout);
10678
- this.proc.on("error", (err) => this.push({ type: "error", message: err.message }));
10679
- this.proc.on("exit", () => {
10986
+ this.proc.on("error", (err) => {
10987
+ this.push({ type: "error", message: err.message });
10988
+ this.ready.reject(err);
10989
+ });
10990
+ this.proc.on("exit", (code) => {
10680
10991
  this.push({ type: "disconnected" });
10681
10992
  this.closed = true;
10682
10993
  this.flushQueue();
10994
+ if (code) {
10995
+ this.ready.reject(new Error(`ACP agent exited (${code})${this.stderr.trim() ? `: ${this.stderr.trim()}` : ""}`));
10996
+ }
10683
10997
  });
10684
10998
  const app = client({ name: "pipe-kan" });
10685
10999
  app.onRequest(methods.client.session.requestPermission, (ctx) => this.handlePermission(ctx.params));
10686
11000
  app.onRequest(methods.client.fs.readTextFile, () => Promise.reject(new Error("read_file not implemented")));
10687
11001
  this.lifetime = Promise.withResolvers();
10688
11002
  const connected = app.connectWith(stream, async (ctx) => {
11003
+ this.agentCtx = ctx;
10689
11004
  this.activeSession = await ctx.buildSession(cwd).start();
11005
+ this.configOptions = this.activeSession.newSessionResponse.configOptions ?? [];
11006
+ try {
11007
+ if (this.config.model)
11008
+ await this.setModel(this.config.model);
11009
+ } catch (err) {
11010
+ this.push({ type: "error", message: `Could not set model ${this.config.model}: ${errorText(err)}` });
11011
+ }
10690
11012
  this.ready.resolve();
10691
11013
  this.push({ type: "connected" });
11014
+ this.push({ type: "config", models: this.models(), selectedModel: this.selectedModel() });
10692
11015
  this.readLoop();
10693
11016
  await this.lifetime.promise;
10694
11017
  });
10695
11018
  connected.catch(() => {});
10696
- await this.ready.promise;
11019
+ const timeout = setTimeout(() => {
11020
+ this.ready.reject(new Error(`Timed out starting ${cmd}${this.stderr.trim() ? `:
11021
+ ${lastLines(this.stderr)}` : ""}`));
11022
+ this.close();
11023
+ }, CONNECT_TIMEOUT_MS);
11024
+ try {
11025
+ await this.ready.promise;
11026
+ } finally {
11027
+ clearTimeout(timeout);
11028
+ }
10697
11029
  }
10698
11030
  async readLoop() {
10699
11031
  if (!this.activeSession)
@@ -10718,7 +11050,11 @@ ${resultText}`);
10718
11050
  const tool = this.detectToolCall?.(this.textBuffer);
10719
11051
  if (tool) {
10720
11052
  this.toolCalls.set(tool.requestId, tool);
10721
- this.push({ type: "tool_call", ...tool });
11053
+ if (this.shouldAutoApproveTool?.(tool.name)) {
11054
+ this.executeTool?.(tool).then(({ text, result }) => this.resolveToolCall(tool.requestId, text, result));
11055
+ } else {
11056
+ this.push({ type: "tool_call", ...tool });
11057
+ }
10722
11058
  return;
10723
11059
  }
10724
11060
  this.push({ type: "agent_message_chunk", text: update.content.text });
@@ -10726,9 +11062,12 @@ ${resultText}`);
10726
11062
  this.push({
10727
11063
  type: "tool_call",
10728
11064
  requestId: update.toolCallId ?? crypto.randomUUID(),
10729
- name: update.name ?? "unknown",
11065
+ name: update.title ?? "unknown",
10730
11066
  args: {}
10731
11067
  });
11068
+ } else if (update.sessionUpdate === "config_option_update") {
11069
+ this.configOptions = update.configOptions ?? this.configOptions;
11070
+ this.push({ type: "config", models: this.models(), selectedModel: this.selectedModel() });
10732
11071
  }
10733
11072
  }
10734
11073
  handlePermission(params) {
@@ -10738,7 +11077,7 @@ ${resultText}`);
10738
11077
  this.push({
10739
11078
  type: "request_permission",
10740
11079
  requestId,
10741
- kind: params.toolCall.name ?? "unknown",
11080
+ kind: params.toolCall.title ?? "unknown",
10742
11081
  description: params.toolCall.content ? JSON.stringify(params.toolCall.content) : ""
10743
11082
  });
10744
11083
  return promise.then((outcome) => ({ outcome }));
@@ -10754,19 +11093,23 @@ ${resultText}`);
10754
11093
  }
10755
11094
  }
10756
11095
  push(event) {
10757
- const resolver = this.eventResolvers.shift();
10758
- if (resolver) {
10759
- resolver({ value: event, done: false });
10760
- } else {
11096
+ if (this.listeners.size === 0) {
10761
11097
  this.eventsQueue.push(event);
11098
+ return;
10762
11099
  }
11100
+ for (const listener of this.listeners)
11101
+ listener(event);
10763
11102
  }
10764
11103
  flushQueue() {
10765
- while (this.eventResolvers.length) {
10766
- this.eventResolvers.shift()({ value: undefined, done: true });
10767
- }
11104
+ this.eventsQueue.length = 0;
11105
+ for (const listener of this.listeners)
11106
+ listener({ type: "disconnected" });
10768
11107
  }
10769
11108
  }
11109
+ function lastLines(text, n = 12) {
11110
+ return text.trim().split(/\n/).slice(-n).join(`
11111
+ `);
11112
+ }
10770
11113
  function toContentBlock(ctx) {
10771
11114
  if (ctx.type === "resource") {
10772
11115
  return {
@@ -10862,6 +11205,8 @@ function parseFrontMatter(text) {
10862
11205
  }
10863
11206
 
10864
11207
  // src/server/agent/tools.ts
11208
+ import { readFileSync as readFileSync3 } from "node:fs";
11209
+ import { join as join5 } from "node:path";
10865
11210
  var TOOLS = [
10866
11211
  {
10867
11212
  name: "board_state",
@@ -10875,6 +11220,18 @@ var TOOLS = [
10875
11220
  parameters: { key: { type: "string", description: "Issue key, e.g. DEMO-123" } },
10876
11221
  mutates: false
10877
11222
  },
11223
+ {
11224
+ name: "run_skill",
11225
+ description: "Load a skill by id and return its instructions as context.",
11226
+ parameters: { skillId: { type: "string", description: "Skill id, e.g. triage" } },
11227
+ mutates: false
11228
+ },
11229
+ {
11230
+ name: "read_repo_file",
11231
+ description: "Read a file under the repo root as plain text.",
11232
+ parameters: { path: { type: "string", description: "Relative repo path" } },
11233
+ mutates: false
11234
+ },
10878
11235
  {
10879
11236
  name: "move_card",
10880
11237
  description: "Move a card to a new status. Requires user approval because it changes Jira via jira-cli.",
@@ -10903,6 +11260,7 @@ var TOOLS = [
10903
11260
  mutates: true
10904
11261
  }
10905
11262
  ];
11263
+ var skills = createSkillRegistry();
10906
11264
  var EXECUTORS = {
10907
11265
  board_state(_, app) {
10908
11266
  const board = app.board();
@@ -10921,6 +11279,32 @@ var EXECUTORS = {
10921
11279
  return { ok: false, error: result.error };
10922
11280
  return { ok: true, value: { url: result.url, fields: result.fields } };
10923
11281
  },
11282
+ run_skill(args) {
11283
+ const skillId = String(args.skillId ?? "");
11284
+ if (!skillId)
11285
+ return { ok: false, error: "Missing skillId" };
11286
+ const skill = skills.load(skillId);
11287
+ if (!skill)
11288
+ return { ok: false, error: `Skill not found: ${skillId}` };
11289
+ const block = skillContextBlock(skill);
11290
+ if (block.type !== "resource")
11291
+ return { ok: false, error: "Skill produced unexpected block" };
11292
+ return { ok: true, value: block.resource };
11293
+ },
11294
+ read_repo_file(args) {
11295
+ const relPath = String(args.path ?? "");
11296
+ if (!relPath)
11297
+ return { ok: false, error: "Missing path" };
11298
+ if (relPath.includes(".."))
11299
+ return { ok: false, error: "Path traversal not allowed" };
11300
+ const repoRoot = process.cwd();
11301
+ try {
11302
+ const text = readFileSync3(join5(repoRoot, relPath), "utf8");
11303
+ return { ok: true, value: { path: relPath, text } };
11304
+ } catch (err) {
11305
+ return { ok: false, error: String(err) };
11306
+ }
11307
+ },
10924
11308
  async move_card(args, app) {
10925
11309
  const key = String(args.key ?? "");
10926
11310
  const status = String(args.status ?? "");
@@ -10940,16 +11324,18 @@ var EXECUTORS = {
10940
11324
  const name = String(args.name ?? "");
10941
11325
  if (!name)
10942
11326
  return { ok: false, error: "Missing preset name" };
10943
- return { ok: true, value: `Preset '${name}' would be applied by the UI when tool execution is wired there.` };
11327
+ return { ok: true, value: { __ui_action: "apply_preset", preset: name } };
10944
11328
  },
10945
11329
  set_filter(args) {
10946
11330
  const filter = args.filter;
10947
11331
  if (!filter || typeof filter !== "object")
10948
11332
  return { ok: false, error: "Missing filter object" };
10949
- return { ok: true, value: `Filter set to ${JSON.stringify(filter)}. UI should apply this filter.` };
11333
+ return { ok: true, value: { __ui_action: "set_filter", filter } };
10950
11334
  }
10951
11335
  };
10952
- var SYSTEM_TEXT = `You can call tools by emitting a single JSON code block matching this schema:
11336
+ var SYSTEM_TEXT = `Your working context is the attached Jira issues from the pipe. Answer from that payload. Do not search GitHub, git history, or local source files unless the user explicitly asks about the pipe-kan app itself.
11337
+
11338
+ You can call tools by emitting a single JSON code block matching this schema:
10953
11339
 
10954
11340
  {"tool": "<name>", "args": {...}}
10955
11341
 
@@ -10986,6 +11372,9 @@ function createToolRegistry() {
10986
11372
  return;
10987
11373
  }
10988
11374
  },
11375
+ isMutating(name) {
11376
+ return TOOLS.find((t) => t.name === name)?.mutates ?? true;
11377
+ },
10989
11378
  async execute(name, args, app) {
10990
11379
  const executor = EXECUTORS[name];
10991
11380
  if (!executor)
@@ -11001,7 +11390,9 @@ function createToolRegistry() {
11001
11390
 
11002
11391
  // src/server/agent/api.ts
11003
11392
  var sessions = new Map;
11004
- var skills = createSkillRegistry();
11393
+ var connecting = null;
11394
+ var startEpoch = 0;
11395
+ var skills2 = createSkillRegistry();
11005
11396
  var tools = createToolRegistry();
11006
11397
  function json2(res, status, body) {
11007
11398
  res.statusCode = status;
@@ -11019,39 +11410,113 @@ function readBody2(req) {
11019
11410
  function pathOf2(req) {
11020
11411
  return new URL(req.url ?? "/", "http://127.0.0.1");
11021
11412
  }
11413
+ function publicConfig(cfg) {
11414
+ return {
11415
+ defaultAgent: cfg.defaultAgent,
11416
+ defaultSkill: cfg.defaultSkill ?? null,
11417
+ agents: Object.entries(cfg.agents).map(([id, c]) => ({
11418
+ id,
11419
+ command: [c.command, ...c.args ?? []].join(" "),
11420
+ model: c.model ?? null,
11421
+ options: c.options,
11422
+ available: commandOnPath(c.command),
11423
+ models: FALLBACK_MODELS[id] ?? []
11424
+ }))
11425
+ };
11426
+ }
11427
+ function attachTools(session, app) {
11428
+ session.setToolParser((text) => {
11429
+ const call = tools.parse(text);
11430
+ return call ? { requestId: call.requestId, name: call.name, args: call.args } : undefined;
11431
+ });
11432
+ session.setToolExecutor((name) => !tools.isMutating(name), async (call) => {
11433
+ const result = await tools.execute(call.name, call.args, app);
11434
+ return {
11435
+ text: result.ok ? `Result: ${JSON.stringify(result.value)}` : `Error: ${result.error}`,
11436
+ result: result.ok ? result.value : undefined
11437
+ };
11438
+ });
11439
+ }
11022
11440
  function handleAgentApi(req, res, app) {
11023
11441
  const url = pathOf2(req);
11024
11442
  const method = (req.method ?? "GET").toUpperCase();
11025
11443
  if (url.pathname === "/api/agent/config" && method === "GET") {
11026
- const cfg = loadAgentConfig();
11027
- json2(res, 200, {
11028
- defaultAgent: cfg.defaultAgent,
11029
- agents: Object.entries(cfg.agents).map(([id, c]) => ({
11030
- id,
11031
- command: [c.command, ...c.args ?? []].join(" ")
11032
- }))
11033
- });
11444
+ json2(res, 200, publicConfig(loadAgentConfig()));
11445
+ return true;
11446
+ }
11447
+ if (url.pathname === "/api/agent/config" && method === "POST") {
11448
+ readBody2(req).then((text) => {
11449
+ const body = text ? JSON.parse(text) : {};
11450
+ json2(res, 200, publicConfig(saveAgentConfig(body)));
11451
+ }).catch((err) => json2(res, 500, { error: String(err) }));
11034
11452
  return true;
11035
11453
  }
11036
11454
  if (url.pathname === "/api/agent/skills" && method === "GET") {
11037
- json2(res, 200, skills.list().map((s) => ({ id: s.id, name: s.name, description: s.description })));
11455
+ json2(res, 200, skills2.list().map((s) => ({ id: s.id, name: s.name, description: s.description })));
11038
11456
  return true;
11039
11457
  }
11040
11458
  if (url.pathname === "/api/agent/session" && method === "POST") {
11041
- const cfg = loadAgentConfig();
11042
- const backendConfig = cfg.agents[cfg.defaultAgent];
11043
- if (!backendConfig) {
11044
- json2(res, 500, { error: `No agent config for ${cfg.defaultAgent}` });
11045
- return true;
11046
- }
11047
- const backend = createAcpBackend(cfg.defaultAgent, cfg.defaultAgent);
11048
- backend.spawn(backendConfig).then((session) => {
11049
- session.setToolParser((text) => {
11050
- const call = tools.parse(text);
11051
- return call ? { requestId: call.requestId, name: call.name, args: call.args } : undefined;
11459
+ readBody2(req).then(async (text) => {
11460
+ const body = text ? JSON.parse(text) : {};
11461
+ const cfg = loadAgentConfig();
11462
+ const agentId = body.agentId || cfg.defaultAgent;
11463
+ const backendConfig = cfg.agents[agentId];
11464
+ if (!backendConfig) {
11465
+ json2(res, 500, { error: `No agent config for ${agentId}` });
11466
+ return;
11467
+ }
11468
+ if (!commandOnPath(backendConfig.command)) {
11469
+ json2(res, 500, { error: `${backendConfig.command} is not on PATH` });
11470
+ return;
11471
+ }
11472
+ const session = createAcpSession({
11473
+ ...backendConfig,
11474
+ model: body.model || backendConfig.model
11052
11475
  });
11053
- sessions.set(session.id, session);
11054
- json2(res, 200, { sessionId: session.id });
11476
+ const epoch = ++startEpoch;
11477
+ const previous = connecting;
11478
+ const old = [...sessions.values()];
11479
+ connecting = session;
11480
+ sessions.clear();
11481
+ await Promise.all([previous, ...old].filter((item) => item != null).map((item) => item.close().catch(() => {
11482
+ return;
11483
+ })));
11484
+ try {
11485
+ await session.connect();
11486
+ if (epoch !== startEpoch) {
11487
+ await session.close().catch(() => {
11488
+ return;
11489
+ });
11490
+ json2(res, 500, { error: "Session replaced" });
11491
+ return;
11492
+ }
11493
+ connecting = null;
11494
+ attachTools(session, app);
11495
+ sessions.set(session.id, session);
11496
+ json2(res, 200, {
11497
+ sessionId: session.id,
11498
+ agentId,
11499
+ models: session.models().length ? session.models() : FALLBACK_MODELS[agentId] ?? [],
11500
+ selectedModel: session.selectedModel()
11501
+ });
11502
+ } catch (err) {
11503
+ if (connecting === session)
11504
+ connecting = null;
11505
+ throw err;
11506
+ }
11507
+ }).catch((err) => json2(res, 500, { error: String(err) }));
11508
+ return true;
11509
+ }
11510
+ if (url.pathname === "/api/agent/model" && method === "POST") {
11511
+ readBody2(req).then(async (text) => {
11512
+ const body = JSON.parse(text);
11513
+ const session = sessions.get(body.sessionId ?? "");
11514
+ if (!session) {
11515
+ json2(res, 404, { error: "Session not found" });
11516
+ return;
11517
+ }
11518
+ await session.setModel(String(body.model ?? ""));
11519
+ json2(res, 200, { ok: true, models: session.models(), selectedModel: session.selectedModel() });
11055
11520
  }).catch((err) => json2(res, 500, { error: String(err) }));
11056
11521
  return true;
11057
11522
  }
@@ -11063,9 +11528,9 @@ function handleAgentApi(req, res, app) {
11063
11528
  json2(res, 404, { error: "Session not found" });
11064
11529
  return;
11065
11530
  }
11066
- const context = [tools.systemBlock(), ...body.context ?? []];
11531
+ const context = [tools.systemBlock(), ...withPipedBoardContext(app.board(), body.context ?? [])];
11067
11532
  if (body.skillId) {
11068
- const skill = skills.load(body.skillId);
11533
+ const skill = skills2.load(body.skillId);
11069
11534
  if (skill)
11070
11535
  context.push(skillContextBlock(skill));
11071
11536
  }
@@ -11105,7 +11570,7 @@ function handleAgentApi(req, res, app) {
11105
11570
  } else {
11106
11571
  const result = await tools.execute(toolCall.name, toolCall.args, app);
11107
11572
  const resultText = result.ok ? `Result: ${JSON.stringify(result.value)}` : `Error: ${result.error}`;
11108
- session.resolveToolCall(requestId, resultText);
11573
+ session.resolveToolCall(requestId, resultText, result.ok ? result.value : undefined);
11109
11574
  }
11110
11575
  json2(res, 200, { ok: true });
11111
11576
  return;
@@ -11135,21 +11600,13 @@ function handleAgentApi(req, res, app) {
11135
11600
 
11136
11601
  `);
11137
11602
  };
11138
- (async () => {
11139
- for await (const event of session.events()) {
11140
- writeEvent(event);
11141
- if (event.type === "disconnected") {
11142
- break;
11143
- }
11144
- }
11145
- if (!res.writableEnded)
11146
- res.end();
11147
- })().catch((err) => {
11148
- writeEvent({ type: "error", message: String(err) });
11149
- if (!res.writableEnded)
11603
+ const unsubscribe = session.subscribe((event) => {
11604
+ writeEvent(event);
11605
+ if (event.type === "disconnected" && !res.writableEnded)
11150
11606
  res.end();
11151
11607
  });
11152
11608
  req.on("close", () => {
11609
+ unsubscribe();
11153
11610
  if (!res.writableEnded)
11154
11611
  res.end();
11155
11612
  });
@@ -11264,12 +11721,12 @@ function handleRequest(req, res, ctx) {
11264
11721
  }
11265
11722
 
11266
11723
  // src/jira-config.ts
11267
- import { mkdirSync, writeFileSync } from "node:fs";
11268
- import { join as join5 } from "node:path";
11724
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
11725
+ import { join as join6 } from "node:path";
11269
11726
  function writeJiraConfig(dir, server) {
11270
- mkdirSync(dir, { recursive: true });
11271
- const path = join5(dir, "jira.config.yml");
11272
- writeFileSync(path, [
11727
+ mkdirSync2(dir, { recursive: true });
11728
+ const path = join6(dir, "jira.config.yml");
11729
+ writeFileSync2(path, [
11273
11730
  "installation: Cloud",
11274
11731
  `server: ${server}`,
11275
11732
  `login: ${ME.emailAddress}`,
@@ -11324,7 +11781,7 @@ function stdinStat() {
11324
11781
 
11325
11782
  // src/ui.ts
11326
11783
  import { createReadStream, existsSync as existsSync4, statSync } from "node:fs";
11327
- import { dirname, extname, join as join6, resolve as resolve2, sep } from "node:path";
11784
+ import { dirname as dirname2, extname, join as join7, resolve as resolve2, sep } from "node:path";
11328
11785
  import { fileURLToPath } from "node:url";
11329
11786
  var types = {
11330
11787
  ".css": "text/css; charset=utf-8",
@@ -11338,10 +11795,10 @@ var types = {
11338
11795
  ".woff2": "font/woff2"
11339
11796
  };
11340
11797
  function packageRoot(from = import.meta.url) {
11341
- return resolve2(dirname(fileURLToPath(from)), "..");
11798
+ return resolve2(dirname2(fileURLToPath(from)), "..");
11342
11799
  }
11343
11800
  function uiDir(root) {
11344
- return join6(root, "dist", "ui");
11801
+ return join7(root, "dist", "ui");
11345
11802
  }
11346
11803
  function inside(root, file) {
11347
11804
  const base = resolve2(root);
@@ -11350,7 +11807,7 @@ function inside(root, file) {
11350
11807
  }
11351
11808
  function sendUi(root, req, res) {
11352
11809
  const ui = uiDir(root);
11353
- const index = join6(ui, "index.html");
11810
+ const index = join7(ui, "index.html");
11354
11811
  if (!existsSync4(index))
11355
11812
  return false;
11356
11813
  const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
@@ -11371,7 +11828,7 @@ function announce(line) {
11371
11828
  }
11372
11829
  async function runServer(opts) {
11373
11830
  const piped = await readPipe();
11374
- const raw = piped ?? JSON.parse(readFileSync3(join7(opts.root, "fixtures/issues.json"), "utf8"));
11831
+ const raw = piped ?? JSON.parse(readFileSync4(join8(opts.root, "fixtures/issues.json"), "utf8"));
11375
11832
  const { app, store, kind } = await createBoardApp({
11376
11833
  raw,
11377
11834
  piped: Boolean(piped)
@@ -11391,7 +11848,7 @@ async function runServer(opts) {
11391
11848
  const { host, port } = resolveListen();
11392
11849
  await bindListen(server, host, port);
11393
11850
  const origin = `http://127.0.0.1:${port}`;
11394
- const fakeConfig = writeJiraConfig(join7(tmpdir2(), "pipe-kan"), origin);
11851
+ const fakeConfig = writeJiraConfig(join8(tmpdir2(), "pipe-kan"), origin);
11395
11852
  announce(`pipe-kan http://${host}:${port}`);
11396
11853
  announce(`cli ${kind === "jira" ? resolveJiraBin() : "store"}`);
11397
11854
  announce(`Fake Jira ${origin}/rest/api/2/search`);