@oh-my-pi/pi-coding-agent 15.2.2 → 15.2.4

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 (52) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/types/cli/worktree-cli.d.ts +26 -0
  3. package/dist/types/commands/worktree.d.ts +34 -0
  4. package/dist/types/config/settings-schema.d.ts +23 -0
  5. package/dist/types/hashline/constants.d.ts +0 -2
  6. package/dist/types/hashline/hash.d.ts +13 -39
  7. package/dist/types/hashline/parser.d.ts +2 -6
  8. package/dist/types/modes/shared.d.ts +9 -0
  9. package/dist/types/modes/theme/shimmer.d.ts +21 -10
  10. package/dist/types/session/agent-session.d.ts +2 -0
  11. package/dist/types/session/yield-queue.d.ts +24 -0
  12. package/dist/types/slash-commands/helpers/format.d.ts +1 -1
  13. package/dist/types/task/worktree.d.ts +0 -1
  14. package/dist/types/utils/git.d.ts +1 -0
  15. package/package.json +7 -7
  16. package/src/autoresearch/storage.ts +14 -2
  17. package/src/cli/worktree-cli.ts +291 -0
  18. package/src/cli.ts +1 -0
  19. package/src/commands/worktree.ts +56 -0
  20. package/src/config/prompt-templates.ts +1 -8
  21. package/src/config/settings-schema.ts +16 -0
  22. package/src/edit/index.ts +1 -1
  23. package/src/edit/renderer.ts +5 -7
  24. package/src/edit/streaming.ts +24 -12
  25. package/src/hashline/constants.ts +0 -3
  26. package/src/hashline/diff.ts +1 -1
  27. package/src/hashline/execute.ts +2 -2
  28. package/src/hashline/grammar.lark +7 -8
  29. package/src/hashline/hash.ts +21 -43
  30. package/src/hashline/input.ts +15 -13
  31. package/src/hashline/parser.ts +62 -161
  32. package/src/internal-urls/docs-index.generated.ts +2 -2
  33. package/src/modes/components/mcp-add-wizard.ts +4 -3
  34. package/src/modes/components/settings-selector.ts +23 -10
  35. package/src/modes/components/welcome.ts +77 -35
  36. package/src/modes/controllers/event-controller.ts +2 -1
  37. package/src/modes/controllers/mcp-command-controller.ts +4 -3
  38. package/src/modes/interactive-mode.ts +51 -10
  39. package/src/modes/shared.ts +16 -0
  40. package/src/modes/theme/shimmer.ts +173 -33
  41. package/src/modes/utils/ui-helpers.ts +31 -13
  42. package/src/prompts/tools/async-result.md +5 -2
  43. package/src/prompts/tools/hashline.md +62 -81
  44. package/src/sdk.ts +95 -21
  45. package/src/session/agent-session.ts +22 -0
  46. package/src/session/yield-queue.ts +155 -0
  47. package/src/slash-commands/helpers/format.ts +4 -1
  48. package/src/task/worktree.ts +2 -7
  49. package/src/tools/gh.ts +35 -32
  50. package/src/utils/commit-message-generator.ts +6 -1
  51. package/src/utils/git.ts +4 -0
  52. package/src/utils/title-generator.ts +45 -13
package/src/tools/gh.ts CHANGED
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { scheduler } from "node:timers/promises";
5
5
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
6
6
 
7
- import { getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
+ import { getWorktreeDir, hashPath, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import * as z from "zod/v4";
9
9
  import type { Settings } from "../config/settings";
10
10
  import githubDescription from "../prompts/tools/github.md" with { type: "text" };
@@ -859,18 +859,8 @@ function sanitizeRemoteName(value: string): string {
859
859
  return sanitized.length > 0 ? `fork-${sanitized}` : "fork";
860
860
  }
861
861
 
862
- /**
863
- * Encode an absolute repository path into a single filesystem-safe segment.
864
- * Mirrors the legacy session-dir encoding used elsewhere in the project: drop
865
- * the leading separator, then collapse `/`, `\\`, and `:` to `-`. The result
866
- * is not strictly injective for pathological inputs (e.g. `/a/b` vs `/a-b`)
867
- * but matches the rest of the codebase and stays human-readable.
868
- */
869
- function encodeRepoPathForFilesystem(repoPath: string): string {
870
- const resolved = path.resolve(repoPath);
871
- const encoded = resolved.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
872
- return encoded || "root";
873
- }
862
+ /** Maximum disambiguation suffixes we try before giving up on a worktree path. */
863
+ const WORKTREE_PATH_MAX_SUFFIX = 100;
874
864
 
875
865
  function toLocalBranchRef(value: string): string {
876
866
  return `refs/heads/${value}`;
@@ -912,25 +902,38 @@ async function requireCurrentGitHead(cwd: string, signal?: AbortSignal): Promise
912
902
  return headSha;
913
903
  }
914
904
 
915
- async function ensureGitWorktreePathAvailable(
916
- worktreePath: string,
905
+ /**
906
+ * Resolve a worktree path that is free of conflicts.
907
+ *
908
+ * Given a `basePath`, return either `basePath` itself or `${basePath}-2`,
909
+ * `${basePath}-3`, … up to {@link WORKTREE_PATH_MAX_SUFFIX} — whichever is the
910
+ * first variant that is **not** registered with git as another worktree and
911
+ * **not** present on disk. The numeric tail salvages two rare cases that
912
+ * would otherwise abort a checkout: stale leftover dirs from an interrupted
913
+ * `git worktree add`, and the (vanishingly unlikely) `hashPath` collision
914
+ * between two repos that happen to produce the same 7-hex digest.
915
+ */
916
+ async function resolveAvailableWorktreePath(
917
+ basePath: string,
917
918
  existingWorktrees: git.GitWorktreeEntry[],
918
- ): Promise<void> {
919
- const normalizedTarget = path.resolve(worktreePath);
920
- const conflictingWorktree = existingWorktrees.find(entry => path.resolve(entry.path) === normalizedTarget);
921
- if (conflictingWorktree) {
922
- throw new ToolError(`worktree path is already registered: ${conflictingWorktree.path}`);
923
- }
924
-
925
- try {
926
- await fs.stat(normalizedTarget);
927
- throw new ToolError(`worktree path already exists: ${normalizedTarget}`);
928
- } catch (error) {
929
- if (isEnoent(error)) {
930
- return;
919
+ ): Promise<string> {
920
+ const registered = new Set(existingWorktrees.map(entry => path.resolve(entry.path)));
921
+ for (let attempt = 0; attempt < WORKTREE_PATH_MAX_SUFFIX; attempt += 1) {
922
+ const candidate = attempt === 0 ? basePath : `${basePath}-${attempt + 1}`;
923
+ const normalized = path.resolve(candidate);
924
+ if (registered.has(normalized)) continue;
925
+ try {
926
+ await fs.stat(normalized);
927
+ } catch (error) {
928
+ if (isEnoent(error)) {
929
+ return candidate;
930
+ }
931
+ throw error;
931
932
  }
932
- throw error;
933
933
  }
934
+ throw new ToolError(
935
+ `could not find an unused worktree path under ${basePath} (tried ${WORKTREE_PATH_MAX_SUFFIX} suffixes)`,
936
+ );
934
937
  }
935
938
 
936
939
  function selectPrCloneUrl(originUrl: string | undefined, repo: Pick<GhRepoViewData, "url" | "sshUrl">): string {
@@ -2939,7 +2942,7 @@ async function checkoutPullRequest(
2939
2942
  const repoRoot = await requireGitRepoRoot(session.cwd, signal);
2940
2943
  const primaryRepoRoot = await requirePrimaryGitRepoRoot(repoRoot, signal);
2941
2944
  const localBranch = `pr-${prNumber}`;
2942
- const worktreePath = path.join(getWorktreesDir(), encodeRepoPathForFilesystem(primaryRepoRoot), localBranch);
2945
+ const worktreePath = getWorktreeDir(`${prNumber}-${hashPath(primaryRepoRoot)}`);
2943
2946
 
2944
2947
  // Every git mutation against `repoRoot` from here on must run under the
2945
2948
  // per-repo lock. Worktrees of the same primary repo share `.git/config`,
@@ -3003,9 +3006,9 @@ async function checkoutPullRequest(
3003
3006
  signal,
3004
3007
  );
3005
3008
 
3006
- const finalWorktreePath = existingWorktree?.path ?? worktreePath;
3009
+ let finalWorktreePath = existingWorktree?.path ?? worktreePath;
3007
3010
  if (!existingWorktree) {
3008
- await ensureGitWorktreePathAvailable(finalWorktreePath, existingWorktrees);
3011
+ finalWorktreePath = await resolveAvailableWorktreePath(worktreePath, existingWorktrees);
3009
3012
  await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true });
3010
3013
  await git.worktree.add(repoRoot, finalWorktreePath, localBranch, { signal });
3011
3014
  }
@@ -15,6 +15,8 @@ import { toReasoningEffort } from "../thinking";
15
15
 
16
16
  const COMMIT_SYSTEM_PROMPT = prompt.render(commitSystemPrompt);
17
17
  const MAX_DIFF_CHARS = 4000;
18
+ const COMMIT_MAX_TOKENS = 60;
19
+ const REASONING_SAFE_MAX_TOKENS = 1024;
18
20
 
19
21
  /** File patterns that should be excluded from commit message generation diffs. */
20
22
  const NOISE_SUFFIXES = [".lock", ".lockb", "-lock.json", "-lock.yaml"];
@@ -99,13 +101,16 @@ export async function generateCommitMessage(
99
101
  if (!apiKey) continue;
100
102
 
101
103
  try {
104
+ const maxTokens = candidate.model.reasoning
105
+ ? Math.max(COMMIT_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS)
106
+ : COMMIT_MAX_TOKENS;
102
107
  const response = await completeSimple(
103
108
  candidate.model,
104
109
  {
105
110
  systemPrompt: [COMMIT_SYSTEM_PROMPT],
106
111
  messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
107
112
  },
108
- { apiKey, maxTokens: 60, reasoning: toReasoningEffort(candidate.thinkingLevel) },
113
+ { apiKey, maxTokens, reasoning: toReasoningEffort(candidate.thinkingLevel) },
109
114
  );
110
115
 
111
116
  if (response.stopReason === "error") {
package/src/utils/git.ts CHANGED
@@ -1157,6 +1157,10 @@ export const worktree = {
1157
1157
  async list(cwd: string, signal?: AbortSignal): Promise<GitWorktreeEntry[]> {
1158
1158
  return parseWorktreeList(await runText(cwd, ["worktree", "list", "--porcelain"], { readOnly: true, signal }));
1159
1159
  },
1160
+
1161
+ async prune(cwd: string, signal?: AbortSignal): Promise<void> {
1162
+ await runEffect(cwd, ["worktree", "prune"], { signal });
1163
+ },
1160
1164
  };
1161
1165
 
1162
1166
  // ════════════════════════════════════════════════════════════════════════════
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import * as path from "node:path";
5
5
 
6
- import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
6
+ import { type Api, type AssistantMessage, completeSimple, type Model, type Tool } from "@oh-my-pi/pi-ai";
7
7
  import { logger, prompt } from "@oh-my-pi/pi-utils";
8
8
  import type { ModelRegistry } from "../config/model-registry";
9
9
  import { resolveRoleSelection } from "../config/model-resolver";
@@ -16,6 +16,25 @@ const DEFAULT_TERMINAL_TITLE = "π";
16
16
  const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
17
17
 
18
18
  const MAX_INPUT_CHARS = 2000;
19
+ const TITLE_MAX_TOKENS = 30;
20
+ const REASONING_SAFE_MAX_TOKENS = 1024;
21
+ const SET_TITLE_TOOL_NAME = "set_title";
22
+
23
+ const setTitleTool: Tool = {
24
+ name: SET_TITLE_TOOL_NAME,
25
+ description: "Set the generated session title.",
26
+ parameters: {
27
+ type: "object",
28
+ properties: {
29
+ title: {
30
+ type: "string",
31
+ description: "A concise 3-6 word title for the session.",
32
+ },
33
+ },
34
+ required: ["title"],
35
+ additionalProperties: false,
36
+ },
37
+ };
19
38
 
20
39
  function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel?: Model<Api>): Model<Api> | undefined {
21
40
  const availableModels = registry.getAvailable();
@@ -76,14 +95,16 @@ ${truncatedMessage}
76
95
  // account_uuid rather than the snapshot-at-call-site value.
77
96
  const metadata = metadataResolver?.(model.provider);
78
97
 
79
- // Title generation is a 3-6 word task; force reasoning off so reasoning models
80
- // don't burn the entire output budget on internal thinking and return an empty
81
- // string. With reasoning disabled, 30 tokens of output is plenty.
98
+ // Title generation is a 3-6 word task, but some reasoning backends ignore
99
+ // disableReasoning. Keep the normal cheap budget for non-reasoning models
100
+ // while reserving enough output room for reasoning models to still emit
101
+ // the forced tool call after any unavoidable thinking tokens.
102
+ const maxTokens = model.reasoning ? Math.max(TITLE_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : TITLE_MAX_TOKENS;
82
103
  const request = {
83
104
  model: `${model.provider}/${model.id}`,
84
105
  systemPrompt: TITLE_SYSTEM_PROMPT,
85
106
  userMessage,
86
- maxTokens: 30,
107
+ maxTokens,
87
108
  };
88
109
  logger.debug("title-generator: request", request);
89
110
 
@@ -93,11 +114,13 @@ ${truncatedMessage}
93
114
  {
94
115
  systemPrompt: [request.systemPrompt],
95
116
  messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
117
+ tools: [setTitleTool],
96
118
  },
97
119
  {
98
120
  apiKey,
99
- maxTokens: 30,
121
+ maxTokens: request.maxTokens,
100
122
  disableReasoning: true,
123
+ toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
101
124
  metadata,
102
125
  },
103
126
  );
@@ -111,13 +134,7 @@ ${truncatedMessage}
111
134
  return null;
112
135
  }
113
136
 
114
- let title = "";
115
- for (const content of response.content) {
116
- if (content.type === "text") {
117
- title += content.text;
118
- }
119
- }
120
- title = title.trim();
137
+ const title = extractGeneratedTitle(response.content);
121
138
 
122
139
  logger.debug("title-generator: response", {
123
140
  model: request.model,
@@ -140,6 +157,21 @@ ${truncatedMessage}
140
157
  }
141
158
  }
142
159
 
160
+ function extractGeneratedTitle(contentBlocks: AssistantMessage["content"]): string {
161
+ let textTitle = "";
162
+ for (const content of contentBlocks) {
163
+ if (content.type === "toolCall" && content.name === SET_TITLE_TOOL_NAME) {
164
+ const args = content.arguments as Record<string, unknown>;
165
+ const title = args.title;
166
+ return typeof title === "string" ? title.trim() : "";
167
+ }
168
+ if (content.type === "text") {
169
+ textTitle += content.text;
170
+ }
171
+ }
172
+ return textTitle.trim();
173
+ }
174
+
143
175
  /**
144
176
  * Remove control characters so model-generated titles cannot inject terminal escapes.
145
177
  */