@oharness/engine 0.10.0 → 0.12.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 (118) hide show
  1. package/dist/acp/claude.d.ts +34 -0
  2. package/dist/acp/claude.js +122 -0
  3. package/dist/acp/claude.js.map +1 -0
  4. package/dist/acp/client.d.ts +193 -0
  5. package/dist/acp/client.js +279 -0
  6. package/dist/acp/client.js.map +1 -0
  7. package/dist/acp/runner.d.ts +80 -0
  8. package/dist/acp/runner.js +159 -0
  9. package/dist/acp/runner.js.map +1 -0
  10. package/dist/agent/agent.d.ts +51 -1
  11. package/dist/agent/agent.js +322 -1
  12. package/dist/agent/agent.js.map +1 -1
  13. package/dist/agent/hooks.js +6 -6
  14. package/dist/agent/hooks.js.map +1 -1
  15. package/dist/agent/session.d.ts +2 -0
  16. package/dist/agent/session.js +19 -2
  17. package/dist/agent/session.js.map +1 -1
  18. package/dist/agent/skills.d.ts +16 -3
  19. package/dist/agent/skills.js +36 -2
  20. package/dist/agent/skills.js.map +1 -1
  21. package/dist/agent/subagent.d.ts +24 -2
  22. package/dist/agent/subagent.js +76 -16
  23. package/dist/agent/subagent.js.map +1 -1
  24. package/dist/auth/index.d.ts +3 -1
  25. package/dist/auth/index.js +3 -1
  26. package/dist/auth/index.js.map +1 -1
  27. package/dist/auth/manager.js +4 -0
  28. package/dist/auth/manager.js.map +1 -1
  29. package/dist/auth/oauth.d.ts +33 -0
  30. package/dist/auth/oauth.js +59 -8
  31. package/dist/auth/oauth.js.map +1 -1
  32. package/dist/auth/providers/anthropic.d.ts +25 -5
  33. package/dist/auth/providers/anthropic.js +47 -6
  34. package/dist/auth/providers/anthropic.js.map +1 -1
  35. package/dist/auth/providers/chatgpt.d.ts +8 -0
  36. package/dist/auth/providers/chatgpt.js +180 -0
  37. package/dist/auth/providers/chatgpt.js.map +1 -0
  38. package/dist/auth/providers/openrouter.d.ts +2 -0
  39. package/dist/auth/providers/openrouter.js +101 -0
  40. package/dist/auth/providers/openrouter.js.map +1 -0
  41. package/dist/auth/types.d.ts +15 -0
  42. package/dist/auth/types.js.map +1 -1
  43. package/dist/browser/index.d.ts +1 -1
  44. package/dist/browser/plugin.d.ts +6 -1
  45. package/dist/browser/plugin.js.map +1 -1
  46. package/dist/browser/session.d.ts +47 -0
  47. package/dist/browser/session.js +177 -2
  48. package/dist/browser/session.js.map +1 -1
  49. package/dist/browser/tools.js +139 -18
  50. package/dist/browser/tools.js.map +1 -1
  51. package/dist/computer/cua-driver.d.ts +60 -1
  52. package/dist/computer/cua-driver.js +149 -5
  53. package/dist/computer/cua-driver.js.map +1 -1
  54. package/dist/computer/helper-client.d.ts +3 -1
  55. package/dist/computer/helper-client.js +3 -0
  56. package/dist/computer/helper-client.js.map +1 -1
  57. package/dist/computer/index.d.ts +3 -2
  58. package/dist/computer/index.js +1 -1
  59. package/dist/computer/index.js.map +1 -1
  60. package/dist/computer/mode.d.ts +15 -0
  61. package/dist/computer/mode.js +11 -1
  62. package/dist/computer/mode.js.map +1 -1
  63. package/dist/computer/session.d.ts +36 -7
  64. package/dist/computer/session.js +118 -17
  65. package/dist/computer/session.js.map +1 -1
  66. package/dist/computer/target.d.ts +19 -0
  67. package/dist/computer/target.js.map +1 -1
  68. package/dist/computer/tools.js +135 -8
  69. package/dist/computer/tools.js.map +1 -1
  70. package/dist/config.d.ts +16 -0
  71. package/dist/config.js +2 -0
  72. package/dist/config.js.map +1 -1
  73. package/dist/foreign.d.ts +81 -0
  74. package/dist/foreign.js +198 -0
  75. package/dist/foreign.js.map +1 -0
  76. package/dist/harness.d.ts +79 -0
  77. package/dist/harness.js +241 -17
  78. package/dist/harness.js.map +1 -1
  79. package/dist/mcp/manager.d.ts +15 -0
  80. package/dist/mcp/manager.js +28 -1
  81. package/dist/mcp/manager.js.map +1 -1
  82. package/dist/mcp/registry.d.ts +2 -0
  83. package/dist/mcp/registry.js +1 -0
  84. package/dist/mcp/registry.js.map +1 -1
  85. package/dist/mcp/registry.json +394 -1
  86. package/dist/paths.d.ts +19 -0
  87. package/dist/paths.js +38 -0
  88. package/dist/paths.js.map +1 -1
  89. package/dist/plugin-market.d.ts +57 -0
  90. package/dist/plugin-market.js +229 -0
  91. package/dist/plugin-market.js.map +1 -0
  92. package/dist/plugin-market.json +1246 -0
  93. package/dist/plugins.d.ts +49 -2
  94. package/dist/plugins.js +252 -10
  95. package/dist/plugins.js.map +1 -1
  96. package/dist/providers/catalog.json +14 -3
  97. package/dist/providers/models-dev.d.ts +15 -0
  98. package/dist/providers/models-dev.js +14 -0
  99. package/dist/providers/models-dev.js.map +1 -1
  100. package/dist/providers/piai.d.ts +38 -1
  101. package/dist/providers/piai.js +70 -2
  102. package/dist/providers/piai.js.map +1 -1
  103. package/dist/providers/registry.d.ts +16 -1
  104. package/dist/providers/registry.js +42 -11
  105. package/dist/providers/registry.js.map +1 -1
  106. package/dist/runtime-plugins/builtin/studio.d.ts +7 -0
  107. package/dist/runtime-plugins/builtin/studio.js +69 -4
  108. package/dist/runtime-plugins/builtin/studio.js.map +1 -1
  109. package/dist/tools/builtin/bash.js +6 -10
  110. package/dist/tools/builtin/bash.js.map +1 -1
  111. package/dist/util/shell.d.ts +30 -0
  112. package/dist/util/shell.js +70 -0
  113. package/dist/util/shell.js.map +1 -1
  114. package/dist/util/toml.d.ts +22 -0
  115. package/dist/util/toml.js +305 -0
  116. package/dist/util/toml.js.map +1 -0
  117. package/package.json +4 -4
  118. package/skills/media-generation/SKILL.md +22 -1
@@ -1,5 +1,7 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { GOAL_MAX_TURNS, GOAL_TOOL, MAX_GOAL_NUDGES, activeGoal, goalNudge, goalPromptSection, goalTool } from "./goal.js";
2
- import { COMPUTER_KEY, computerOn, computerPromptSection, isComputerTool } from "../computer/mode.js";
4
+ import { COMPUTER_ENABLE_TOOL, COMPUTER_KEY, computerOn, computerPromptSection, isComputerTool } from "../computer/mode.js";
3
5
  import { randomUUID } from "node:crypto";
4
6
  import { errorMessage } from "../core/errors.js";
5
7
  import { estimateMessagesTokens, estimateStringTokens, estimateToolsTokens } from "../core/tokens.js";
@@ -28,6 +30,7 @@ import { createSkillTool } from "../tools/builtin/skill.js";
28
30
  import { createMcpResourceTools } from "../tools/builtin/mcp-resource.js";
29
31
  import { HookRegistry } from "./hooks.js";
30
32
  import { buildSystemPrompt } from "./prompt.js";
33
+ import { AcpSignInRequired, AcpUnavailable, runAcpTurn } from "../acp/runner.js";
31
34
  import { ASK_USER_TOOL } from "../tools/questions.js";
32
35
  import { SubagentRegistry } from "./subagent.js";
33
36
  function withoutSignal(opts) {
@@ -643,6 +646,8 @@ export class Agent {
643
646
  // background tasks: it has none of its own.
644
647
  const childTools = definition.tools ? this.tools.filtered(definition.tools) : this.tools.clone();
645
648
  childTools.unregister(ASK_USER_TOOL);
649
+ // Nor may it switch the screen on for itself; see the COMPUTER_KEY rule above.
650
+ childTools.unregister(COMPUTER_ENABLE_TOOL);
646
651
  childTools.unregister(TASK_OUTPUT_TOOL);
647
652
  childTools.unregister(TASK_STOP_TOOL);
648
653
  childTools.unregister(TASK_MESSAGE_TOOL);
@@ -689,6 +694,8 @@ export class Agent {
689
694
  * the task say what the subagent is doing now.
690
695
  */
691
696
  async runChild(child, input, signal, report) {
697
+ if (child.definition.runtime?.kind === "acp")
698
+ return this.runExternalChild(child, input, signal, report);
692
699
  let text = "";
693
700
  let turns = 0;
694
701
  let usage = emptyUsage();
@@ -723,6 +730,279 @@ export class Agent {
723
730
  });
724
731
  return { text, turns, usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } };
725
732
  }
733
+ /**
734
+ * One run of a subagent whose loop is an external agent's (ACP).
735
+ *
736
+ * The child session is still kept, as the record of the task: the prompt
737
+ * and the agent's answer go into it, so a background task's transcript and
738
+ * a follow-up after a restart read the same as any other subagent's. The
739
+ * agent itself starts fresh each run, so what came before rides in the
740
+ * prompt.
741
+ */
742
+ async runExternalChild(child, input, signal, report) {
743
+ const runtime = child.definition.runtime;
744
+ const session = child.agent.session;
745
+ const earlier = session.messages
746
+ .map((message) => `${message.role === "user" ? "Asked" : "You answered"}: ${messageText(message).trim()}`)
747
+ .filter((line) => !line.endsWith(": "));
748
+ const prompt = earlier.length
749
+ ? `Earlier in this task:\n\n${earlier.join("\n\n")}\n\nNow: ${input}`
750
+ : input;
751
+ // The agent's own session from the last run, when it ran in this folder:
752
+ // a follow-up resumes it, so the agent keeps what it learned, and only
753
+ // the new message is sent. Another folder (a replaced worktree) is not
754
+ // the same session, whatever its id.
755
+ const last = [...session.messages].reverse().find((message) => message.role === "assistant");
756
+ const previous = last?.meta?.["acpSession"];
757
+ const resume = typeof previous?.id === "string" && previous.cwd === session.cwd
758
+ ? { sessionId: previous.id, prompt: input }
759
+ : undefined;
760
+ const servers = await this.forwardedMcpServers();
761
+ await session.addMessage({ role: "user", content: [{ type: "text", text: input }], meta: { timestamp: Date.now() } });
762
+ report?.turn();
763
+ let text;
764
+ let acpSession;
765
+ try {
766
+ const result = await runAcpTurn({
767
+ runtime,
768
+ cwd: session.cwd,
769
+ prompt,
770
+ ...(resume ? { resume } : {}),
771
+ ...(servers.length ? { mcpServers: servers } : {}),
772
+ // Its file reads and writes through the same policy as ours: in the
773
+ // project (or its worktree), and nowhere it was not allowed to go.
774
+ fs: child.agent.externalFs(),
775
+ ...(signal ? { signal } : {}),
776
+ askPermission: (title, toolInput, toolCallId) => child.agent.askForExternal(title, toolInput, toolCallId, signal),
777
+ onActivity: (title) => report?.activity(title),
778
+ });
779
+ text = result.text || (result.stopReason === "cancelled" ? "Stopped." : "The agent finished without saying anything.");
780
+ acpSession = { id: result.sessionId, cwd: session.cwd };
781
+ }
782
+ catch (err) {
783
+ // Said as the answer rather than thrown: "sign in to Claude Code" is
784
+ // what the parent has to pass on, not a crash to report.
785
+ if (err instanceof AcpSignInRequired || err instanceof AcpUnavailable)
786
+ text = err.message;
787
+ else
788
+ throw err;
789
+ }
790
+ await session.addMessage({
791
+ role: "assistant",
792
+ content: [{ type: "text", text }],
793
+ meta: { timestamp: Date.now(), ...(acpSession ? { acpSession } : {}) },
794
+ });
795
+ await this.hooks.run({
796
+ event: "SubagentStop",
797
+ sessionId: this.session.id,
798
+ agentId: `${this.agentId}/${child.definition.name}`,
799
+ cwd: this.session.cwd,
800
+ });
801
+ // No usage: the external agent bills its own account and reports none.
802
+ return { text, turns: 1, usage: { inputTokens: 0, outputTokens: 0 } };
803
+ }
804
+ /**
805
+ * File access for an external agent, through this agent's workspace policy:
806
+ * reads where ours may read, writes where ours may write, nowhere else.
807
+ */
808
+ externalFs() {
809
+ return {
810
+ read: async (file, line, limit) => {
811
+ const text = await readFile(this.resolvePath(file, "read"), "utf8");
812
+ if (line === undefined && limit === undefined)
813
+ return text;
814
+ const lines = text.split("\n");
815
+ const start = Math.max(0, (line ?? 1) - 1);
816
+ return lines.slice(start, limit === undefined ? undefined : start + limit).join("\n");
817
+ },
818
+ write: async (file, content) => {
819
+ const target = this.resolvePath(file, "write");
820
+ await mkdir(path.dirname(target), { recursive: true });
821
+ await writeFile(target, content);
822
+ },
823
+ };
824
+ }
825
+ /**
826
+ * `@claude …`: a message addressed to an external agent by name.
827
+ *
828
+ * Only an agent with an ACP runtime answers to this, and only at the start
829
+ * of what was typed — or of the paragraph after a reference note, which the
830
+ * page puts above the prompt. The note stays with the message; the name
831
+ * does not.
832
+ */
833
+ directTarget(input) {
834
+ if (typeof input !== "string")
835
+ return undefined;
836
+ const match = /(^|\n\n)@([\w./-]+)[ \t]+(\S[\s\S]*)$/.exec(input);
837
+ if (!match)
838
+ return undefined;
839
+ const definition = this.subagents.get(match[2]);
840
+ if (definition?.runtime?.kind !== "acp")
841
+ return undefined;
842
+ const note = input.slice(0, match.index).trim();
843
+ return { definition, text: note ? `${note}\n\n${match[3]}` : match[3] };
844
+ }
845
+ /**
846
+ * A turn answered by an external agent instead of the model.
847
+ *
848
+ * What makes `@claude` worth having: the message goes straight to the
849
+ * user's own Claude Code, so it is paid for by their Claude account and
850
+ * nothing is spent on this harness's model in between. The agent's reply
851
+ * streams into the conversation as text, its tool calls as tool cards, its
852
+ * questions as the usual permission card; the exchange is kept as an
853
+ * ordinary user/assistant pair, and the agent's session id rides on the
854
+ * reply so the next `@claude` picks up where this one left off.
855
+ */
856
+ async *runDirect(target, raw, signal) {
857
+ const { definition, text } = target;
858
+ const runtime = definition.runtime;
859
+ const finishRun = this.opts.onRunStart?.();
860
+ let stopReason = "end_turn";
861
+ try {
862
+ await this.session.init();
863
+ await this.session.addMessage(userMessage(raw));
864
+ yield { type: "turn_start", turn: 1, model: definition.name };
865
+ const previous = [...this.session.messages]
866
+ .reverse()
867
+ .find((message) => message.role === "assistant" && message.meta?.["agent"] === definition.name)
868
+ ?.meta?.["acpSession"];
869
+ const resume = typeof previous?.id === "string" && previous.cwd === this.session.cwd
870
+ ? { sessionId: previous.id, prompt: text }
871
+ : undefined;
872
+ const events = channel();
873
+ const started = new Map();
874
+ let reply = "";
875
+ const servers = await this.forwardedMcpServers();
876
+ const turn = runAcpTurn({
877
+ runtime,
878
+ cwd: this.session.cwd,
879
+ prompt: text,
880
+ ...(resume ? { resume } : {}),
881
+ ...(servers.length ? { mcpServers: servers } : {}),
882
+ fs: this.externalFs(),
883
+ signal,
884
+ askPermission: (title, input, toolCallId) => this.askForExternal(title, input, toolCallId, signal, definition.name),
885
+ onActivity: (title) => {
886
+ if (title === "Starting the agent…")
887
+ events.push({ type: "notice", message: `Starting ${definition.name}…` });
888
+ },
889
+ onUpdate: (update) => {
890
+ if (update.sessionUpdate === "agent_message_chunk" || update.sessionUpdate === "agent_thought_chunk") {
891
+ const content = update.content;
892
+ if (content?.type !== "text" || !content.text)
893
+ return;
894
+ if (update.sessionUpdate === "agent_thought_chunk")
895
+ return events.push({ type: "thinking_delta", text: content.text });
896
+ reply += content.text;
897
+ events.push({ type: "text_delta", text: content.text });
898
+ }
899
+ else if (update.sessionUpdate === "tool_call") {
900
+ const call = update;
901
+ const id = `${definition.name}:${call.toolCallId}`;
902
+ started.set(id, Date.now());
903
+ events.push({
904
+ type: "tool_start",
905
+ toolCallId: id,
906
+ toolName: call.kind || "tool",
907
+ title: call.title || call.kind || "tool",
908
+ input: call.rawInput ?? {},
909
+ });
910
+ }
911
+ else if (update.sessionUpdate === "tool_call_update") {
912
+ const call = update;
913
+ const id = `${definition.name}:${call.toolCallId}`;
914
+ if ((call.status !== "completed" && call.status !== "failed") || !started.has(id))
915
+ return;
916
+ const began = started.get(id);
917
+ started.delete(id);
918
+ events.push({
919
+ type: "tool_end",
920
+ toolCallId: id,
921
+ toolName: call.kind || "tool",
922
+ result: { content: [{ type: "text", text: call.status }], ...(call.status === "failed" ? { isError: true } : {}) },
923
+ durationMs: Date.now() - began,
924
+ });
925
+ }
926
+ },
927
+ });
928
+ void turn.then(() => events.close(), () => events.close());
929
+ for await (const event of events.drain())
930
+ yield event;
931
+ let acpSession;
932
+ try {
933
+ const result = await turn;
934
+ acpSession = { id: result.sessionId, cwd: this.session.cwd };
935
+ if (result.stopReason === "cancelled" || signal.aborted)
936
+ stopReason = "aborted";
937
+ if (!reply.trim())
938
+ reply = result.text;
939
+ }
940
+ catch (err) {
941
+ if (err instanceof AcpSignInRequired || err instanceof AcpUnavailable) {
942
+ reply = err.message;
943
+ yield { type: "text_delta", text: err.message };
944
+ }
945
+ else if (signal.aborted) {
946
+ stopReason = "aborted";
947
+ }
948
+ else {
949
+ stopReason = "error";
950
+ yield { type: "error", error: `${definition.name}: ${errorMessage(err)}`, fatal: false };
951
+ }
952
+ }
953
+ if (reply.trim()) {
954
+ const message = {
955
+ role: "assistant",
956
+ content: [{ type: "text", text: reply }],
957
+ meta: { timestamp: Date.now(), agent: definition.name, ...(acpSession ? { acpSession } : {}) },
958
+ };
959
+ await this.session.addMessage(message);
960
+ yield { type: "assistant_message", message };
961
+ }
962
+ }
963
+ finally {
964
+ this.running = false;
965
+ await this.session.persist().catch(() => { });
966
+ finishRun?.();
967
+ }
968
+ yield { type: "done", stopReason, usage: this.session.usage, costUsd: this.session.costUsd, turns: 1 };
969
+ }
970
+ /**
971
+ * This conversation's MCP servers, in the shape ACP's `session/new` takes,
972
+ * so an external agent works with the same tools. HTTP ones are filtered
973
+ * out later for an agent that does not take them.
974
+ */
975
+ async forwardedMcpServers() {
976
+ const pairs = (values) => Object.entries(values ?? {}).map(([name, value]) => ({ name, value }));
977
+ const found = await this.opts.mcp?.forwardable().catch(() => []) ?? [];
978
+ return found.map(({ name, config, headers }) => config.type === "stdio"
979
+ ? { name, command: config.command, args: config.args ?? [], env: pairs(config.env) }
980
+ : { type: "http", name, url: config.url, headers: pairs({ ...config.headers, ...headers }) });
981
+ }
982
+ /**
983
+ * An external agent's own tool call, put to the person the way this
984
+ * harness's calls are — the same card, placed in the conversation that
985
+ * started the task and labelled with who is asking. Plan mode and a
986
+ * read-only workspace refuse; bypass allows, as it would for our own tools.
987
+ */
988
+ async askForExternal(title, input, toolCallId, signal, from) {
989
+ const mode = this.session.permissions.getMode();
990
+ if (this.opts.workspace?.mode === "read-only" || mode === "plan" || !this.opts.permissionPrompt)
991
+ return false;
992
+ if (mode === "bypassPermissions")
993
+ return true;
994
+ const request = {
995
+ toolName: "external",
996
+ input,
997
+ title,
998
+ ...this.interactionPlace(),
999
+ ...(from ? { from } : {}),
1000
+ toolCallId,
1001
+ };
1002
+ const prompt = this.opts.permissionPrompt;
1003
+ const ask = () => prompt(request, signal);
1004
+ return signal ? untilAborted(signal, ask, false, false) : ask();
1005
+ }
726
1006
  /** What a subagent left in its worktree, when it left anything. */
727
1007
  async worktreeNote(child) {
728
1008
  if (!child.worktree)
@@ -767,6 +1047,12 @@ export class Agent {
767
1047
  this.running = false;
768
1048
  throw new Error("Agent host is closed");
769
1049
  }
1050
+ // Addressed to an external agent by name: its turn, not the model's.
1051
+ const direct = this.directTarget(input);
1052
+ if (direct) {
1053
+ yield* this.runDirect(direct, input, signal);
1054
+ return;
1055
+ }
770
1056
  // Fired once per session rather than per turn: hooks use it to seed
771
1057
  // context or announce the session, which should not repeat every prompt.
772
1058
  if (!this.sessionStarted) {
@@ -1301,6 +1587,9 @@ export class Agent {
1301
1587
  return this.tools
1302
1588
  .visible(this.opts.toolFilter)
1303
1589
  .filter((tool) => (goal || tool.name !== GOAL_TOOL) && (computer || !isComputerTool(tool.name)))
1590
+ // Asking for the computer is for a conversation without it and with
1591
+ // somebody there: a routine or a headless run has nobody to answer.
1592
+ .filter((tool) => tool.name !== COMPUTER_ENABLE_TOOL || (!computer && Boolean(this.opts.permissionPrompt)))
1304
1593
  .filter((tool) => worktrees || tool.name !== WORKTREE_START_TOOL)
1305
1594
  .filter((tool) => background || ![TASK_OUTPUT_TOOL, TASK_STOP_TOOL, TASK_MESSAGE_TOOL].includes(tool.name));
1306
1595
  }
@@ -1778,4 +2067,36 @@ function touchesSkills(toolName, input) {
1778
2067
  // what is advertised, so re-scanning for those would be noise.
1779
2068
  return target.includes("skills") && target.endsWith("SKILL.md");
1780
2069
  }
2070
+ /**
2071
+ * A queue a callback pushes into and a generator drains, for turning an
2072
+ * external agent's callbacks into a stream of events as they happen.
2073
+ */
2074
+ function channel() {
2075
+ const items = [];
2076
+ let wake;
2077
+ let closed = false;
2078
+ const poke = () => {
2079
+ wake?.();
2080
+ wake = undefined;
2081
+ };
2082
+ return {
2083
+ push(item) {
2084
+ items.push(item);
2085
+ poke();
2086
+ },
2087
+ close() {
2088
+ closed = true;
2089
+ poke();
2090
+ },
2091
+ async *drain() {
2092
+ for (;;) {
2093
+ while (items.length)
2094
+ yield items.shift();
2095
+ if (closed)
2096
+ return;
2097
+ await new Promise((resolve) => (wake = resolve));
2098
+ }
2099
+ },
2100
+ };
2101
+ }
1781
2102
  //# sourceMappingURL=agent.js.map