@oh-my-pi/pi-coding-agent 15.2.4 → 15.3.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/CHANGELOG.md +20 -0
- package/dist/types/config/settings-schema.d.ts +34 -1
- package/dist/types/config/settings.d.ts +6 -0
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/goals/runtime.d.ts +4 -0
- package/dist/types/modes/components/status-line/types.d.ts +10 -0
- package/dist/types/modes/components/status-line.d.ts +10 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/context-usage.d.ts +17 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/task/executor.d.ts +3 -1
- package/dist/types/task/types.d.ts +35 -0
- package/dist/types/tools/bash-command-fixup.d.ts +0 -5
- package/dist/types/utils/clipboard.d.ts +3 -1
- package/dist/types/utils/image-resize.d.ts +4 -1
- package/package.json +7 -7
- package/src/config/settings-schema.ts +29 -1
- package/src/config/settings.ts +19 -0
- package/src/discovery/helpers.ts +5 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
- package/src/goals/runtime.ts +35 -13
- package/src/main.ts +1 -1
- package/src/modes/components/model-selector.ts +53 -22
- package/src/modes/components/status-line/segments.ts +53 -0
- package/src/modes/components/status-line/types.ts +4 -0
- package/src/modes/components/status-line.ts +147 -12
- package/src/modes/controllers/command-controller.ts +9 -0
- package/src/modes/controllers/event-controller.ts +8 -0
- package/src/modes/interactive-mode.ts +23 -8
- package/src/modes/theme/theme.ts +1 -1
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/context-usage.ts +25 -2
- package/src/modes/utils/ui-helpers.ts +11 -1
- package/src/prompts/agents/frontmatter.md +1 -0
- package/src/sdk.ts +24 -0
- package/src/session/agent-session.ts +58 -0
- package/src/session/session-manager.ts +54 -1
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/task/executor.ts +50 -1
- package/src/task/index.ts +11 -0
- package/src/task/render.ts +26 -2
- package/src/task/types.ts +35 -0
- package/src/tools/bash-command-fixup.ts +0 -10
- package/src/tools/bash.ts +1 -9
- package/src/utils/clipboard.ts +68 -3
- package/src/utils/image-resize.ts +51 -26
- package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
- package/src/modes/components/status-line-segment-editor.ts +0 -359
package/src/sdk.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type AgentMessage,
|
|
5
5
|
type AgentTelemetryConfig,
|
|
6
6
|
type AgentTool,
|
|
7
|
+
AppendOnlyContextManager,
|
|
7
8
|
INTENT_FIELD,
|
|
8
9
|
type ThinkingLevel,
|
|
9
10
|
} from "@oh-my-pi/pi-agent-core";
|
|
@@ -589,6 +590,24 @@ function registerPythonCleanup(): void {
|
|
|
589
590
|
postmortem.register("python-cleanup", disposeAllKernelSessions);
|
|
590
591
|
}
|
|
591
592
|
|
|
593
|
+
/**
|
|
594
|
+
* Resolve whether to enable append-only context mode based on the setting and provider.
|
|
595
|
+
*
|
|
596
|
+
* - `"on"` → always enable
|
|
597
|
+
* - `"off"` → never enable
|
|
598
|
+
* - `"auto"` → enable for DeepSeek (prefix-caching provider)
|
|
599
|
+
*/
|
|
600
|
+
function resolveAppendOnlyMode(setting: "auto" | "on" | "off" | undefined, provider: string): boolean {
|
|
601
|
+
switch (setting ?? "auto") {
|
|
602
|
+
case "on":
|
|
603
|
+
return true;
|
|
604
|
+
case "off":
|
|
605
|
+
return false;
|
|
606
|
+
default:
|
|
607
|
+
return provider === "deepseek";
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
592
611
|
function customToolToDefinition(tool: CustomTool): ToolDefinition {
|
|
593
612
|
const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
|
|
594
613
|
name: tool.name,
|
|
@@ -1897,6 +1916,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1897
1916
|
intentTracing: !!intentField,
|
|
1898
1917
|
getToolChoice: () => session?.nextToolChoice(),
|
|
1899
1918
|
telemetry: options.telemetry,
|
|
1919
|
+
appendOnlyContext: model
|
|
1920
|
+
? resolveAppendOnlyMode(settings.get("provider.appendOnlyContext"), model.provider)
|
|
1921
|
+
? new AppendOnlyContextManager()
|
|
1922
|
+
: undefined
|
|
1923
|
+
: undefined,
|
|
1900
1924
|
});
|
|
1901
1925
|
|
|
1902
1926
|
cursorEventEmitter = event => agent.emitExternalEvent(event);
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type AgentMessage,
|
|
27
27
|
type AgentState,
|
|
28
28
|
type AgentTool,
|
|
29
|
+
AppendOnlyContextManager,
|
|
29
30
|
resolveTelemetry,
|
|
30
31
|
ThinkingLevel,
|
|
31
32
|
} from "@oh-my-pi/pi-agent-core";
|
|
@@ -98,6 +99,7 @@ import {
|
|
|
98
99
|
} from "../config/model-resolver";
|
|
99
100
|
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
|
|
100
101
|
import type { Settings, SkillsSettings } from "../config/settings";
|
|
102
|
+
import { onAppendOnlyModeChanged } from "../config/settings";
|
|
101
103
|
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
|
|
102
104
|
import { loadCapability } from "../discovery";
|
|
103
105
|
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
|
@@ -1138,6 +1140,8 @@ export class AgentSession {
|
|
|
1138
1140
|
// Always subscribe to agent events for internal handling
|
|
1139
1141
|
// (session persistence, hooks, auto-compaction, retry logic)
|
|
1140
1142
|
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
|
|
1143
|
+
// Re-evaluate append-only context mode when the setting changes at runtime.
|
|
1144
|
+
onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
|
|
1141
1145
|
}
|
|
1142
1146
|
|
|
1143
1147
|
/** Model registry for API key resolution and model discovery */
|
|
@@ -3573,6 +3577,18 @@ export class AgentSession {
|
|
|
3573
3577
|
return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
|
|
3574
3578
|
}
|
|
3575
3579
|
|
|
3580
|
+
/**
|
|
3581
|
+
* Whether idle-flush tasks, auto-continuations, or other short-lived
|
|
3582
|
+
* post-prompt work are pending. True in the brief window after
|
|
3583
|
+
* `session.prompt()` returns but before a scheduled background delivery
|
|
3584
|
+
* (e.g. an async-job result) has finished its own streaming turn.
|
|
3585
|
+
* Loop-mode and similar auto-submit paths should treat this as a block
|
|
3586
|
+
* to avoid racing against the delivery turn.
|
|
3587
|
+
*/
|
|
3588
|
+
get hasPostPromptWork(): boolean {
|
|
3589
|
+
return this.#postPromptTasks.size > 0;
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3576
3592
|
/** All messages including custom types like BashExecutionMessage */
|
|
3577
3593
|
get messages(): AgentMessage[] {
|
|
3578
3594
|
return this.agent.state.messages;
|
|
@@ -5947,6 +5963,9 @@ export class AgentSession {
|
|
|
5947
5963
|
this.#closeProviderSessionsForModelSwitch(currentModel, model);
|
|
5948
5964
|
}
|
|
5949
5965
|
this.agent.setModel(model);
|
|
5966
|
+
|
|
5967
|
+
// Re-evaluate append-only context mode — provider or setting may have changed
|
|
5968
|
+
this.#syncAppendOnlyContext(model);
|
|
5950
5969
|
}
|
|
5951
5970
|
|
|
5952
5971
|
#closeCodexProviderSessionsForHistoryRewrite(): void {
|
|
@@ -5955,6 +5974,24 @@ export class AgentSession {
|
|
|
5955
5974
|
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
|
|
5956
5975
|
}
|
|
5957
5976
|
|
|
5977
|
+
/**
|
|
5978
|
+
* Re-evaluate append-only context mode, creating or destroying the
|
|
5979
|
+
* manager as needed. Called on model switch AND setting change.
|
|
5980
|
+
*/
|
|
5981
|
+
#syncAppendOnlyContext(model: Model | null | undefined): void {
|
|
5982
|
+
const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
|
|
5983
|
+
const enable = setting === "on" || (setting === "auto" && model?.provider === "deepseek");
|
|
5984
|
+
if (enable && !this.agent.appendOnlyContext) {
|
|
5985
|
+
this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
|
|
5986
|
+
} else if (enable && this.agent.appendOnlyContext) {
|
|
5987
|
+
// Already active — invalidate prefix + log so the next turn
|
|
5988
|
+
// rebuilds for the current model's normalization.
|
|
5989
|
+
this.agent.appendOnlyContext.invalidateForModelChange();
|
|
5990
|
+
} else if (!enable && this.agent.appendOnlyContext) {
|
|
5991
|
+
this.agent.setAppendOnlyContext(undefined);
|
|
5992
|
+
}
|
|
5993
|
+
}
|
|
5994
|
+
|
|
5958
5995
|
#closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
|
|
5959
5996
|
const providerKeys = new Set<string>();
|
|
5960
5997
|
if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
|
|
@@ -7071,6 +7108,27 @@ export class AgentSession {
|
|
|
7071
7108
|
}
|
|
7072
7109
|
}
|
|
7073
7110
|
|
|
7111
|
+
// Fail-fast cap: if the provider asks us to wait longer than
|
|
7112
|
+
// retry.maxDelayMs and we have no fallback credential or model to
|
|
7113
|
+
// switch to, surface the error instead of sleeping. Defends against
|
|
7114
|
+
// 3-hour Anthropic rate-limit windows that would otherwise leave a
|
|
7115
|
+
// subagent (or interactive session) silently hung. The original
|
|
7116
|
+
// assistant error message is preserved in agent state so the caller
|
|
7117
|
+
// can act on it.
|
|
7118
|
+
const maxDelayMs = retrySettings.maxDelayMs;
|
|
7119
|
+
if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
|
|
7120
|
+
const attempt = this.#retryAttempt;
|
|
7121
|
+
this.#retryAttempt = 0;
|
|
7122
|
+
await this.#emitSessionEvent({
|
|
7123
|
+
type: "auto_retry_end",
|
|
7124
|
+
success: false,
|
|
7125
|
+
attempt,
|
|
7126
|
+
finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${maxDelayMs}ms). Original error: ${errorMessage}`,
|
|
7127
|
+
});
|
|
7128
|
+
this.#resolveRetry();
|
|
7129
|
+
return false;
|
|
7130
|
+
}
|
|
7131
|
+
|
|
7074
7132
|
await this.#emitSessionEvent({
|
|
7075
7133
|
type: "auto_retry_start",
|
|
7076
7134
|
attempt: this.#retryAttempt,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
getProjectDir,
|
|
19
19
|
getSessionsDir,
|
|
20
20
|
getTerminalSessionsDir,
|
|
21
|
+
hasFsCode,
|
|
21
22
|
isEnoent,
|
|
22
23
|
logger,
|
|
23
24
|
parseJsonlLenient,
|
|
@@ -2146,7 +2147,59 @@ export class SessionManager {
|
|
|
2146
2147
|
{ ignoreError: true },
|
|
2147
2148
|
);
|
|
2148
2149
|
}
|
|
2150
|
+
// Windows can reject overwrite-style rename with EPERM even after our own writer is closed.
|
|
2151
|
+
// Move the old session file aside first so a failed retry can roll back to the last good file.
|
|
2149
2152
|
|
|
2153
|
+
async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise<void> {
|
|
2154
|
+
const dir = path.resolve(targetPath, "..");
|
|
2155
|
+
const backupPath = path.join(dir, `.${path.basename(targetPath)}.${Snowflake.next()}.bak`);
|
|
2156
|
+
try {
|
|
2157
|
+
await this.storage.rename(targetPath, backupPath);
|
|
2158
|
+
} catch (err) {
|
|
2159
|
+
if (isEnoent(err)) {
|
|
2160
|
+
await this.storage.rename(tempPath, targetPath);
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
throw toError(renameError);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
try {
|
|
2167
|
+
await this.storage.rename(tempPath, targetPath);
|
|
2168
|
+
} catch (err) {
|
|
2169
|
+
const replaceError = toError(err);
|
|
2170
|
+
try {
|
|
2171
|
+
await this.storage.rename(backupPath, targetPath);
|
|
2172
|
+
} catch (rollbackErr) {
|
|
2173
|
+
const rollbackError = toError(rollbackErr);
|
|
2174
|
+
throw new Error(
|
|
2175
|
+
`Failed to replace session file after EPERM (${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
|
|
2176
|
+
{ cause: replaceError },
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
throw replaceError;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
try {
|
|
2183
|
+
await this.storage.unlink(backupPath);
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
if (!isEnoent(err)) {
|
|
2186
|
+
logger.warn("Failed to remove session rewrite backup", {
|
|
2187
|
+
sessionFile: targetPath,
|
|
2188
|
+
backupPath,
|
|
2189
|
+
error: toError(err).message,
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
async #replaceSessionFile(tempPath: string, targetPath: string): Promise<void> {
|
|
2196
|
+
try {
|
|
2197
|
+
await this.storage.rename(tempPath, targetPath);
|
|
2198
|
+
} catch (err) {
|
|
2199
|
+
if (!hasFsCode(err, "EPERM")) throw toError(err);
|
|
2200
|
+
await this.#replaceSessionFileAfterEperm(tempPath, targetPath, err);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2150
2203
|
async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
|
|
2151
2204
|
if (!this.#sessionFile) return;
|
|
2152
2205
|
const dir = path.resolve(this.#sessionFile, "..");
|
|
@@ -2159,7 +2212,7 @@ export class SessionManager {
|
|
|
2159
2212
|
await writer.flush();
|
|
2160
2213
|
await writer.fsync();
|
|
2161
2214
|
await writer.close();
|
|
2162
|
-
await this
|
|
2215
|
+
await this.#replaceSessionFile(tempPath, this.#sessionFile);
|
|
2163
2216
|
} catch (err) {
|
|
2164
2217
|
try {
|
|
2165
2218
|
await writer.close();
|
|
@@ -72,7 +72,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
72
72
|
inlineHint: "[prompt]",
|
|
73
73
|
allowArgs: true,
|
|
74
74
|
handleTui: async (command, runtime) => {
|
|
75
|
+
const hadArgs = !!command.args;
|
|
75
76
|
await runtime.ctx.handlePlanModeCommand(command.args || undefined);
|
|
77
|
+
if (hadArgs && runtime.ctx.planModeEnabled) {
|
|
78
|
+
// plan was already active — preserve the typed command in input history
|
|
79
|
+
runtime.ctx.editor.addToHistory(command.text);
|
|
80
|
+
}
|
|
76
81
|
runtime.ctx.editor.setText("");
|
|
77
82
|
},
|
|
78
83
|
},
|
|
@@ -90,7 +95,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
90
95
|
inlineHint: "[objective]",
|
|
91
96
|
allowArgs: true,
|
|
92
97
|
handleTui: async (command, runtime) => {
|
|
98
|
+
const hadArgs = !!command.args;
|
|
93
99
|
await runtime.ctx.handleGoalModeCommand(command.args || undefined);
|
|
100
|
+
if (hadArgs && runtime.ctx.goalModeEnabled) {
|
|
101
|
+
// goal was already active — preserve the typed command in input history
|
|
102
|
+
runtime.ctx.editor.addToHistory(command.text);
|
|
103
|
+
}
|
|
94
104
|
runtime.ctx.editor.setText("");
|
|
95
105
|
},
|
|
96
106
|
},
|
package/src/task/executor.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
|
|
|
17
17
|
import type { CustomTool } from "../extensibility/custom-tools/types";
|
|
18
18
|
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
|
|
19
19
|
import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
|
|
20
|
-
import type
|
|
20
|
+
import { buildSkillPromptMessage, type Skill } from "../extensibility/skills";
|
|
21
21
|
import type { HindsightSessionState } from "../hindsight/state";
|
|
22
22
|
import type { LocalProtocolOptions } from "../internal-urls";
|
|
23
23
|
import { callTool } from "../mcp/client";
|
|
@@ -29,6 +29,7 @@ import { createAgentSession, discoverAuthStorage } from "../sdk";
|
|
|
29
29
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
|
30
30
|
import type { ArtifactManager } from "../session/artifacts";
|
|
31
31
|
import type { AuthStorage } from "../session/auth-storage";
|
|
32
|
+
import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
|
|
32
33
|
import { SessionManager } from "../session/session-manager";
|
|
33
34
|
import { truncateTail } from "../session/streaming-output";
|
|
34
35
|
import type { ContextFileEntry } from "../tools";
|
|
@@ -190,6 +191,8 @@ export interface ExecutorOptions {
|
|
|
190
191
|
* transition explicitly.
|
|
191
192
|
*/
|
|
192
193
|
parentTelemetry?: AgentTelemetryConfig;
|
|
194
|
+
/** Skills to autoload via sendCustomMessage before the first prompt */
|
|
195
|
+
autoloadSkills?: Skill[];
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
function parseStringifiedJson(value: unknown): unknown {
|
|
@@ -1347,6 +1350,30 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1347
1350
|
|
|
1348
1351
|
const MAX_YIELD_RETRIES = 3;
|
|
1349
1352
|
unsubscribe = session.subscribe(event => {
|
|
1353
|
+
if (event.type === "auto_retry_start") {
|
|
1354
|
+
progress.retryState = {
|
|
1355
|
+
attempt: event.attempt,
|
|
1356
|
+
maxAttempts: event.maxAttempts,
|
|
1357
|
+
delayMs: event.delayMs,
|
|
1358
|
+
errorMessage: event.errorMessage,
|
|
1359
|
+
startedAtMs: Date.now(),
|
|
1360
|
+
};
|
|
1361
|
+
progress.retryFailure = undefined;
|
|
1362
|
+
scheduleProgress(true);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (event.type === "auto_retry_end") {
|
|
1366
|
+
const attempt = progress.retryState?.attempt ?? event.attempt;
|
|
1367
|
+
progress.retryState = undefined;
|
|
1368
|
+
if (!event.success) {
|
|
1369
|
+
progress.retryFailure = {
|
|
1370
|
+
attempt,
|
|
1371
|
+
errorMessage: event.finalError ?? "Auto-retry failed",
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
scheduleProgress(true);
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1350
1377
|
if (isAgentEvent(event)) {
|
|
1351
1378
|
try {
|
|
1352
1379
|
processEvent(event);
|
|
@@ -1360,6 +1387,21 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1360
1387
|
});
|
|
1361
1388
|
|
|
1362
1389
|
checkAbort();
|
|
1390
|
+
// Autoload skills via sendCustomMessage (same mechanic as /skill:<name>)
|
|
1391
|
+
if (options.autoloadSkills?.length) {
|
|
1392
|
+
for (const skill of options.autoloadSkills) {
|
|
1393
|
+
const { message } = await buildSkillPromptMessage(skill, "");
|
|
1394
|
+
await session.sendCustomMessage(
|
|
1395
|
+
{
|
|
1396
|
+
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
|
1397
|
+
content: message,
|
|
1398
|
+
display: false,
|
|
1399
|
+
details: { name: skill.name, path: skill.filePath },
|
|
1400
|
+
},
|
|
1401
|
+
{ triggerTurn: false },
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1363
1405
|
await awaitAbortable(session.prompt(task, { attribution: "agent" }));
|
|
1364
1406
|
await awaitAbortable(session.waitForIdle());
|
|
1365
1407
|
|
|
@@ -1367,6 +1409,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1367
1409
|
|
|
1368
1410
|
let retryCount = 0;
|
|
1369
1411
|
while (!yieldCalled && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
|
|
1412
|
+
// Skip reminders when the model returned a terminal error (e.g.
|
|
1413
|
+
// rate-limit cap hit, auth failure). Re-prompting would just
|
|
1414
|
+
// hit the same wall, multiplying the failure noise without
|
|
1415
|
+
// any chance of producing a yield.
|
|
1416
|
+
const lastBeforeReminder = session.getLastAssistantMessage();
|
|
1417
|
+
if (lastBeforeReminder?.stopReason === "error") break;
|
|
1370
1418
|
try {
|
|
1371
1419
|
retryCount++;
|
|
1372
1420
|
const reminder = prompt.render(submitReminderTemplate, {
|
|
@@ -1566,6 +1614,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1566
1614
|
usage: hasUsage ? accumulatedUsage : undefined,
|
|
1567
1615
|
outputPath,
|
|
1568
1616
|
extractedToolData: progress.extractedToolData,
|
|
1617
|
+
retryFailure: progress.retryFailure,
|
|
1569
1618
|
outputMeta,
|
|
1570
1619
|
};
|
|
1571
1620
|
}
|
package/src/task/index.ts
CHANGED
|
@@ -410,6 +410,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
410
410
|
progress.contextWindow = singleResult?.contextWindow;
|
|
411
411
|
progress.cost = singleResult?.usage?.cost.total ?? 0;
|
|
412
412
|
progress.extractedToolData = singleResult?.extractedToolData;
|
|
413
|
+
progress.retryFailure = singleResult?.retryFailure;
|
|
414
|
+
progress.retryState = undefined;
|
|
413
415
|
}
|
|
414
416
|
completedJobs += 1;
|
|
415
417
|
if (singleResult && ((singleResult.aborted ?? false) || singleResult.exitCode !== 0)) {
|
|
@@ -830,6 +832,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
830
832
|
const tasksWithUniqueIds = tasks.map((t, i) => ({ ...t, id: uniqueIds[i] }));
|
|
831
833
|
|
|
832
834
|
const availableSkills = [...(this.session.skills ?? [])];
|
|
835
|
+
// Resolve autoload skills from agent definition against available skills
|
|
836
|
+
const resolvedAutoloadSkills =
|
|
837
|
+
agent.autoloadSkills?.length && availableSkills.length > 0
|
|
838
|
+
? agent.autoloadSkills
|
|
839
|
+
.map(name => availableSkills.find(s => s.name === name))
|
|
840
|
+
.filter((s): s is NonNullable<typeof s> => s !== undefined)
|
|
841
|
+
: [];
|
|
833
842
|
const contextFiles = this.session.contextFiles?.filter(
|
|
834
843
|
file => path.basename(file.path).toLowerCase() !== "agents.md",
|
|
835
844
|
);
|
|
@@ -894,6 +903,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
894
903
|
mcpManager: MCPManager.instance(),
|
|
895
904
|
contextFiles,
|
|
896
905
|
skills: availableSkills,
|
|
906
|
+
autoloadSkills: resolvedAutoloadSkills,
|
|
897
907
|
workspaceTree: this.session.workspaceTree,
|
|
898
908
|
promptTemplates,
|
|
899
909
|
localProtocolOptions,
|
|
@@ -948,6 +958,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
948
958
|
mcpManager: MCPManager.instance(),
|
|
949
959
|
contextFiles,
|
|
950
960
|
skills: availableSkills,
|
|
961
|
+
autoloadSkills: resolvedAutoloadSkills,
|
|
951
962
|
workspaceTree: this.session.workspaceTree,
|
|
952
963
|
promptTemplates,
|
|
953
964
|
localProtocolOptions,
|
package/src/task/render.ts
CHANGED
|
@@ -551,8 +551,15 @@ function renderAgentProgress(
|
|
|
551
551
|
const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
|
|
552
552
|
let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
|
|
553
553
|
|
|
554
|
-
//
|
|
555
|
-
|
|
554
|
+
// Show retry-blocked badge so the parent immediately sees that a child
|
|
555
|
+
// is sleeping on a provider 429, not silently progressing. Wins over the
|
|
556
|
+
// generic running spinner because "we're waiting on a quota window" is
|
|
557
|
+
// the operationally meaningful state.
|
|
558
|
+
if (progress.retryState && progress.status === "running") {
|
|
559
|
+
statusLine += ` ${formatBadge("retrying", "warning", theme)}`;
|
|
560
|
+
} else if (progress.retryFailure && (progress.status === "failed" || progress.status === "aborted")) {
|
|
561
|
+
statusLine += ` ${formatBadge("rate-limited", "error", theme)}`;
|
|
562
|
+
} else if (progress.status === "failed" || progress.status === "aborted") {
|
|
556
563
|
const statusLabel = progress.status === "failed" ? "failed" : "aborted";
|
|
557
564
|
statusLine += ` ${formatBadge(statusLabel, iconColor, theme)}`;
|
|
558
565
|
}
|
|
@@ -598,6 +605,23 @@ function renderAgentProgress(
|
|
|
598
605
|
}
|
|
599
606
|
}
|
|
600
607
|
|
|
608
|
+
// Retry detail line: surface why the subagent is paused and roughly how
|
|
609
|
+
// long until the next attempt. Without this, the parent UI would just
|
|
610
|
+
// keep spinning while a child sleeps on a 3-hour provider rate-limit.
|
|
611
|
+
if (progress.retryState && progress.status === "running") {
|
|
612
|
+
const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - Date.now());
|
|
613
|
+
const waitLabel = remainingMs > 0 ? `in ${formatDuration(remainingMs)}` : "now";
|
|
614
|
+
const summary =
|
|
615
|
+
`retrying ${progress.retryState.attempt}/${progress.retryState.maxAttempts} ${waitLabel}: ` +
|
|
616
|
+
truncateToWidth(replaceTabs(progress.retryState.errorMessage), 60);
|
|
617
|
+
lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("warning", summary)}`);
|
|
618
|
+
} else if (progress.retryFailure && progress.status !== "running") {
|
|
619
|
+
const summary = `auto-retry gave up after ${progress.retryFailure.attempt} attempt${
|
|
620
|
+
progress.retryFailure.attempt === 1 ? "" : "s"
|
|
621
|
+
}: ${truncateToWidth(replaceTabs(progress.retryFailure.errorMessage), 80)}`;
|
|
622
|
+
lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("error", summary)}`);
|
|
623
|
+
}
|
|
624
|
+
|
|
601
625
|
// Render extracted tool data inline (e.g., review findings)
|
|
602
626
|
if (progress.extractedToolData) {
|
|
603
627
|
// For completed tasks, check for review verdict from yield tool
|
package/src/task/types.ts
CHANGED
|
@@ -173,6 +173,7 @@ export interface AgentDefinition {
|
|
|
173
173
|
thinkingLevel?: ThinkingLevel;
|
|
174
174
|
output?: unknown;
|
|
175
175
|
blocking?: boolean;
|
|
176
|
+
autoloadSkills?: string[];
|
|
176
177
|
source: AgentSource;
|
|
177
178
|
filePath?: string;
|
|
178
179
|
}
|
|
@@ -211,6 +212,30 @@ export interface AgentProgress {
|
|
|
211
212
|
modelOverride?: string | string[];
|
|
212
213
|
/** Data extracted by registered subprocess tool handlers (keyed by tool name) */
|
|
213
214
|
extractedToolData?: Record<string, unknown[]>;
|
|
215
|
+
/**
|
|
216
|
+
* Auto-retry state when the subagent is sleeping between provider retries
|
|
217
|
+
* (e.g. 429 rate-limit with retry-after). Cleared when the retry resolves
|
|
218
|
+
* or fails. Surfacing this to the parent prevents the task tool from
|
|
219
|
+
* looking indefinitely "in progress" when a child is actually blocked on
|
|
220
|
+
* provider quota.
|
|
221
|
+
*/
|
|
222
|
+
retryState?: {
|
|
223
|
+
attempt: number;
|
|
224
|
+
maxAttempts: number;
|
|
225
|
+
delayMs: number;
|
|
226
|
+
errorMessage: string;
|
|
227
|
+
startedAtMs: number;
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* Terminal retry failure surfaced once the subagent gave up retrying
|
|
231
|
+
* (e.g. retry-after exceeded the cap, or all attempts exhausted). Carries
|
|
232
|
+
* the final error so the parent UI can render "blocked: rate-limited"
|
|
233
|
+
* instead of waiting for a status that never arrives.
|
|
234
|
+
*/
|
|
235
|
+
retryFailure?: {
|
|
236
|
+
attempt: number;
|
|
237
|
+
errorMessage: string;
|
|
238
|
+
};
|
|
214
239
|
}
|
|
215
240
|
|
|
216
241
|
/** Result from a single agent execution */
|
|
@@ -250,6 +275,16 @@ export interface SingleResult {
|
|
|
250
275
|
nestedPatches?: NestedRepoPatch[];
|
|
251
276
|
/** Data extracted by registered subprocess tool handlers (keyed by tool name) */
|
|
252
277
|
extractedToolData?: Record<string, unknown[]>;
|
|
278
|
+
/**
|
|
279
|
+
* Terminal retry failure, when the subagent exited because the auto-retry
|
|
280
|
+
* loop gave up (retry-after exceeded the cap, or all attempts exhausted).
|
|
281
|
+
* Lets the parent task tool surface a "blocked: rate-limited" outcome
|
|
282
|
+
* instead of a generic failure.
|
|
283
|
+
*/
|
|
284
|
+
retryFailure?: {
|
|
285
|
+
attempt: number;
|
|
286
|
+
errorMessage: string;
|
|
287
|
+
};
|
|
253
288
|
/** Output metadata for agent:// URL integration */
|
|
254
289
|
outputMeta?: { lineCount: number; charCount: number };
|
|
255
290
|
}
|
|
@@ -35,13 +35,3 @@ export interface BashFixupResult {
|
|
|
35
35
|
export function applyBashFixups(command: string): BashFixupResult {
|
|
36
36
|
return nativeApplyBashFixups(command);
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Human-readable notice for the fixups that fired. Mirrors the shape of
|
|
41
|
-
* `formatTimeoutClampNotice` so it can ride alongside the other bash notices.
|
|
42
|
-
*/
|
|
43
|
-
export function formatBashFixupNotice(stripped: readonly string[]): string | undefined {
|
|
44
|
-
if (!stripped.length) return undefined;
|
|
45
|
-
const quoted = stripped.map(s => `\`${s}\``).join(", ");
|
|
46
|
-
return `<system-warning>Stripped redundant ${quoted} — bash output is already truncated and stderr is already merged into stdout. NEVER use these patterns.</system-warning>`;
|
|
47
|
-
}
|
package/src/tools/bash.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { renderStatusLine } from "../tui";
|
|
|
17
17
|
import { CachedOutputBlock } from "../tui/output-block";
|
|
18
18
|
import { getSixelLineMask } from "../utils/sixel";
|
|
19
19
|
import type { ToolSession } from ".";
|
|
20
|
-
import { applyBashFixups
|
|
20
|
+
import { applyBashFixups } from "./bash-command-fixup";
|
|
21
21
|
import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
|
|
22
22
|
import { checkBashInterception } from "./bash-interceptor";
|
|
23
23
|
import { canUseInteractiveBashPty } from "./bash-pty-selection";
|
|
@@ -233,7 +233,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
233
233
|
readonly #asyncEnabled: boolean;
|
|
234
234
|
readonly #autoBackgroundEnabled: boolean;
|
|
235
235
|
readonly #autoBackgroundThresholdMs: number;
|
|
236
|
-
#bashFixupNoticeEmitted = false;
|
|
237
236
|
|
|
238
237
|
constructor(private readonly session: ToolSession) {
|
|
239
238
|
this.#asyncEnabled = this.session.settings.get("async.enabled");
|
|
@@ -475,12 +474,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
475
474
|
// Apply conservative bash fixups (strip trailing `| head|tail` and redundant
|
|
476
475
|
// `2>&1`). The helper is single-line only and refuses anything that could
|
|
477
476
|
// change semantics.
|
|
478
|
-
let bashFixups: string[] = [];
|
|
479
477
|
if (this.session.settings.get("bash.stripTrailingHeadTail")) {
|
|
480
478
|
const fixup = applyBashFixups(command);
|
|
481
479
|
if (fixup.stripped.length > 0) {
|
|
482
480
|
command = fixup.command;
|
|
483
|
-
bashFixups = fixup.stripped;
|
|
484
481
|
}
|
|
485
482
|
}
|
|
486
483
|
|
|
@@ -562,11 +559,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
562
559
|
const pendingNotices: string[] = [];
|
|
563
560
|
const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
|
|
564
561
|
if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
|
|
565
|
-
const bashFixupNotice = this.#bashFixupNoticeEmitted ? undefined : formatBashFixupNotice(bashFixups);
|
|
566
|
-
if (bashFixupNotice) {
|
|
567
|
-
pendingNotices.push(bashFixupNotice);
|
|
568
|
-
this.#bashFixupNoticeEmitted = true;
|
|
569
|
-
}
|
|
570
562
|
|
|
571
563
|
if (asyncRequested) {
|
|
572
564
|
if (!AsyncJobManager.instance()) {
|
package/src/utils/clipboard.ts
CHANGED
|
@@ -2,7 +2,13 @@ import { execSync } from "node:child_process";
|
|
|
2
2
|
import type { ClipboardImage } from "@oh-my-pi/pi-natives";
|
|
3
3
|
import * as native from "@oh-my-pi/pi-natives";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
function hasDisplay(): boolean {
|
|
6
|
+
return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isWsl(): boolean {
|
|
10
|
+
return process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
/**
|
|
8
14
|
* Copy text to the system clipboard.
|
|
@@ -59,11 +65,66 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
// PowerShell one-liner that emits the clipboard image as base64-encoded PNG on
|
|
69
|
+
// stdout, or nothing when the clipboard does not hold image data. Used as the
|
|
70
|
+
// WSL bridge — arboard cannot read the Windows clipboard through WSLg.
|
|
71
|
+
const POWERSHELL_IMAGE_SCRIPT = `
|
|
72
|
+
$ErrorActionPreference = 'Stop'
|
|
73
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
74
|
+
Add-Type -AssemblyName System.Drawing
|
|
75
|
+
$img = [System.Windows.Forms.Clipboard]::GetImage()
|
|
76
|
+
if ($img -ne $null) {
|
|
77
|
+
$ms = New-Object System.IO.MemoryStream
|
|
78
|
+
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
79
|
+
[Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const POWERSHELL_TIMEOUT_MS = 5000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read a clipboard image through the Windows host's PowerShell.
|
|
87
|
+
*
|
|
88
|
+
* WSLg exposes a Wayland socket but no native clipboard image transport, so
|
|
89
|
+
* `arboard` returns `ContentNotAvailable`. PowerShell, reached via WSL interop,
|
|
90
|
+
* can read the Windows clipboard directly and round-trip the bitmap as PNG.
|
|
91
|
+
*
|
|
92
|
+
* Returns null when no image is on the clipboard, the host PowerShell is
|
|
93
|
+
* missing, or the bridge times out.
|
|
94
|
+
*/
|
|
95
|
+
async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
|
|
96
|
+
try {
|
|
97
|
+
const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_IMAGE_SCRIPT], {
|
|
98
|
+
stdout: "pipe",
|
|
99
|
+
stderr: "ignore",
|
|
100
|
+
stdin: "ignore",
|
|
101
|
+
});
|
|
102
|
+
const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS);
|
|
103
|
+
let stdout = "";
|
|
104
|
+
try {
|
|
105
|
+
stdout = await new Response(proc.stdout).text();
|
|
106
|
+
await proc.exited;
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
if (proc.exitCode !== 0) return null;
|
|
111
|
+
const b64 = stdout.trim();
|
|
112
|
+
if (!b64) return null;
|
|
113
|
+
const bytes = Buffer.from(b64, "base64");
|
|
114
|
+
if (bytes.byteLength === 0) return null;
|
|
115
|
+
return { data: new Uint8Array(bytes), mimeType: "image/png" };
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
62
121
|
/**
|
|
63
122
|
* Read an image from the system clipboard.
|
|
64
123
|
*
|
|
65
124
|
* Returns null on Termux (no image clipboard support) or when no display
|
|
66
|
-
* server is available (headless/SSH without forwarding).
|
|
125
|
+
* server is available (headless/SSH without forwarding). Under WSL the
|
|
126
|
+
* Windows clipboard is reached through `powershell.exe`, since WSLg's
|
|
127
|
+
* Wayland clipboard does not carry image payloads through to `arboard`.
|
|
67
128
|
*
|
|
68
129
|
* @returns PNG payload or null when no image is available.
|
|
69
130
|
*/
|
|
@@ -72,7 +133,11 @@ export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
|
|
|
72
133
|
return null;
|
|
73
134
|
}
|
|
74
135
|
|
|
75
|
-
if (
|
|
136
|
+
if (isWsl()) {
|
|
137
|
+
const image = await readImageViaPowerShell();
|
|
138
|
+
if (image) return image;
|
|
139
|
+
// Fall through: arboard may still succeed on a future WSLg release.
|
|
140
|
+
} else if (!hasDisplay()) {
|
|
76
141
|
return null;
|
|
77
142
|
}
|
|
78
143
|
|