@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.
- package/CHANGELOG.md +49 -1
- package/dist/types/cli/worktree-cli.d.ts +26 -0
- package/dist/types/commands/worktree.d.ts +34 -0
- package/dist/types/config/settings-schema.d.ts +23 -0
- package/dist/types/hashline/constants.d.ts +0 -2
- package/dist/types/hashline/hash.d.ts +13 -39
- package/dist/types/hashline/parser.d.ts +2 -6
- package/dist/types/modes/shared.d.ts +9 -0
- package/dist/types/modes/theme/shimmer.d.ts +21 -10
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/yield-queue.d.ts +24 -0
- package/dist/types/slash-commands/helpers/format.d.ts +1 -1
- package/dist/types/task/worktree.d.ts +0 -1
- package/dist/types/utils/git.d.ts +1 -0
- package/package.json +7 -7
- package/src/autoresearch/storage.ts +14 -2
- package/src/cli/worktree-cli.ts +291 -0
- package/src/cli.ts +1 -0
- package/src/commands/worktree.ts +56 -0
- package/src/config/prompt-templates.ts +1 -8
- package/src/config/settings-schema.ts +16 -0
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +5 -7
- package/src/edit/streaming.ts +24 -12
- package/src/hashline/constants.ts +0 -3
- package/src/hashline/diff.ts +1 -1
- package/src/hashline/execute.ts +2 -2
- package/src/hashline/grammar.lark +7 -8
- package/src/hashline/hash.ts +21 -43
- package/src/hashline/input.ts +15 -13
- package/src/hashline/parser.ts +62 -161
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/modes/components/mcp-add-wizard.ts +4 -3
- package/src/modes/components/settings-selector.ts +23 -10
- package/src/modes/components/welcome.ts +77 -35
- package/src/modes/controllers/event-controller.ts +2 -1
- package/src/modes/controllers/mcp-command-controller.ts +4 -3
- package/src/modes/interactive-mode.ts +51 -10
- package/src/modes/shared.ts +16 -0
- package/src/modes/theme/shimmer.ts +173 -33
- package/src/modes/utils/ui-helpers.ts +31 -13
- package/src/prompts/tools/async-result.md +5 -2
- package/src/prompts/tools/hashline.md +62 -81
- package/src/sdk.ts +95 -21
- package/src/session/agent-session.ts +22 -0
- package/src/session/yield-queue.ts +155 -0
- package/src/slash-commands/helpers/format.ts +4 -1
- package/src/task/worktree.ts +2 -7
- package/src/tools/gh.ts +35 -32
- package/src/utils/commit-message-generator.ts +6 -1
- package/src/utils/git.ts +4 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
916
|
-
|
|
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<
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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 =
|
|
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
|
-
|
|
3009
|
+
let finalWorktreePath = existingWorktree?.path ?? worktreePath;
|
|
3007
3010
|
if (!existingWorktree) {
|
|
3008
|
-
await
|
|
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
|
|
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
|
|
80
|
-
//
|
|
81
|
-
//
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
*/
|