@oh-my-pi/pi-coding-agent 15.2.4 → 15.3.1
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/model-registry.d.ts +26 -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 +16 -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/session/session-manager.d.ts +10 -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/model-registry.ts +46 -21
- 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/hashline/parser.ts +6 -1
- package/src/internal-urls/docs-index.generated.ts +2 -1
- 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 +243 -15
- 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 +42 -8
- 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 +70 -0
- package/src/session/session-manager.ts +119 -1
- package/src/slash-commands/builtin-registry.ts +15 -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 +79 -3
- package/src/utils/image-resize.ts +78 -30
- package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
- package/src/modes/components/status-line-segment-editor.ts +0 -359
|
@@ -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";
|
|
@@ -749,6 +751,9 @@ export class AgentSession {
|
|
|
749
751
|
|
|
750
752
|
// Event subscription state
|
|
751
753
|
#unsubscribeAgent?: () => void;
|
|
754
|
+
#unsubscribeAppendOnly?: () => void;
|
|
755
|
+
/** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
|
|
756
|
+
#lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
|
|
752
757
|
#eventListeners: AgentSessionEventListener[] = [];
|
|
753
758
|
|
|
754
759
|
/** Tracks pending steering messages for UI display. Removed when delivered.
|
|
@@ -1138,6 +1143,8 @@ export class AgentSession {
|
|
|
1138
1143
|
// Always subscribe to agent events for internal handling
|
|
1139
1144
|
// (session persistence, hooks, auto-compaction, retry logic)
|
|
1140
1145
|
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
|
|
1146
|
+
// Re-evaluate append-only context mode when the setting changes at runtime.
|
|
1147
|
+
this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
|
|
1141
1148
|
}
|
|
1142
1149
|
|
|
1143
1150
|
/** Model registry for API key resolution and model discovery */
|
|
@@ -2781,6 +2788,10 @@ export class AgentSession {
|
|
|
2781
2788
|
await hindsightState?.flushRetainQueue();
|
|
2782
2789
|
hindsightState?.dispose();
|
|
2783
2790
|
this.#disconnectFromAgent();
|
|
2791
|
+
if (this.#unsubscribeAppendOnly) {
|
|
2792
|
+
this.#unsubscribeAppendOnly();
|
|
2793
|
+
this.#unsubscribeAppendOnly = undefined;
|
|
2794
|
+
}
|
|
2784
2795
|
this.#eventListeners = [];
|
|
2785
2796
|
}
|
|
2786
2797
|
|
|
@@ -3573,6 +3584,18 @@ export class AgentSession {
|
|
|
3573
3584
|
return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
|
|
3574
3585
|
}
|
|
3575
3586
|
|
|
3587
|
+
/**
|
|
3588
|
+
* Whether idle-flush tasks, auto-continuations, or other short-lived
|
|
3589
|
+
* post-prompt work are pending. True in the brief window after
|
|
3590
|
+
* `session.prompt()` returns but before a scheduled background delivery
|
|
3591
|
+
* (e.g. an async-job result) has finished its own streaming turn.
|
|
3592
|
+
* Loop-mode and similar auto-submit paths should treat this as a block
|
|
3593
|
+
* to avoid racing against the delivery turn.
|
|
3594
|
+
*/
|
|
3595
|
+
get hasPostPromptWork(): boolean {
|
|
3596
|
+
return this.#postPromptTasks.size > 0;
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3576
3599
|
/** All messages including custom types like BashExecutionMessage */
|
|
3577
3600
|
get messages(): AgentMessage[] {
|
|
3578
3601
|
return this.agent.state.messages;
|
|
@@ -5947,6 +5970,9 @@ export class AgentSession {
|
|
|
5947
5970
|
this.#closeProviderSessionsForModelSwitch(currentModel, model);
|
|
5948
5971
|
}
|
|
5949
5972
|
this.agent.setModel(model);
|
|
5973
|
+
|
|
5974
|
+
// Re-evaluate append-only context mode — provider or setting may have changed
|
|
5975
|
+
this.#syncAppendOnlyContext(model);
|
|
5950
5976
|
}
|
|
5951
5977
|
|
|
5952
5978
|
#closeCodexProviderSessionsForHistoryRewrite(): void {
|
|
@@ -5955,6 +5981,29 @@ export class AgentSession {
|
|
|
5955
5981
|
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
|
|
5956
5982
|
}
|
|
5957
5983
|
|
|
5984
|
+
/**
|
|
5985
|
+
* Re-evaluate append-only context mode, creating or destroying the
|
|
5986
|
+
* manager as needed. Called on model switch AND setting change.
|
|
5987
|
+
*/
|
|
5988
|
+
#syncAppendOnlyContext(model: Model | null | undefined): void {
|
|
5989
|
+
const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
|
|
5990
|
+
const providerId = model?.provider;
|
|
5991
|
+
const enable = setting === "on" || (setting === "auto" && providerId === "deepseek");
|
|
5992
|
+
const prev = this.#lastAppendOnlyResolution;
|
|
5993
|
+
if (prev && prev.enable === enable && prev.providerId === providerId) return;
|
|
5994
|
+
this.#lastAppendOnlyResolution = { enable, providerId };
|
|
5995
|
+
|
|
5996
|
+
if (enable && !this.agent.appendOnlyContext) {
|
|
5997
|
+
this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
|
|
5998
|
+
} else if (enable && this.agent.appendOnlyContext) {
|
|
5999
|
+
// Already active — invalidate prefix + log so the next turn
|
|
6000
|
+
// rebuilds for the current model's normalization.
|
|
6001
|
+
this.agent.appendOnlyContext.invalidateForModelChange();
|
|
6002
|
+
} else if (!enable && this.agent.appendOnlyContext) {
|
|
6003
|
+
this.agent.setAppendOnlyContext(undefined);
|
|
6004
|
+
}
|
|
6005
|
+
}
|
|
6006
|
+
|
|
5958
6007
|
#closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
|
|
5959
6008
|
const providerKeys = new Set<string>();
|
|
5960
6009
|
if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
|
|
@@ -7071,6 +7120,27 @@ export class AgentSession {
|
|
|
7071
7120
|
}
|
|
7072
7121
|
}
|
|
7073
7122
|
|
|
7123
|
+
// Fail-fast cap: if the provider asks us to wait longer than
|
|
7124
|
+
// retry.maxDelayMs and we have no fallback credential or model to
|
|
7125
|
+
// switch to, surface the error instead of sleeping. Defends against
|
|
7126
|
+
// 3-hour Anthropic rate-limit windows that would otherwise leave a
|
|
7127
|
+
// subagent (or interactive session) silently hung. The original
|
|
7128
|
+
// assistant error message is preserved in agent state so the caller
|
|
7129
|
+
// can act on it.
|
|
7130
|
+
const maxDelayMs = retrySettings.maxDelayMs;
|
|
7131
|
+
if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
|
|
7132
|
+
const attempt = this.#retryAttempt;
|
|
7133
|
+
this.#retryAttempt = 0;
|
|
7134
|
+
await this.#emitSessionEvent({
|
|
7135
|
+
type: "auto_retry_end",
|
|
7136
|
+
success: false,
|
|
7137
|
+
attempt,
|
|
7138
|
+
finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${maxDelayMs}ms). Original error: ${errorMessage}`,
|
|
7139
|
+
});
|
|
7140
|
+
this.#resolveRetry();
|
|
7141
|
+
return false;
|
|
7142
|
+
}
|
|
7143
|
+
|
|
7074
7144
|
await this.#emitSessionEvent({
|
|
7075
7145
|
type: "auto_retry_start",
|
|
7076
7146
|
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,
|
|
@@ -941,12 +942,71 @@ function extractFirstUserPrompt(entries: Array<Record<string, unknown>>): string
|
|
|
941
942
|
return undefined;
|
|
942
943
|
}
|
|
943
944
|
|
|
945
|
+
/**
|
|
946
|
+
* Promote orphaned `<basename>.jsonl.<snowflake>.bak` backups created by
|
|
947
|
+
* `#replaceSessionFileAfterEperm` back to their primary path when the primary
|
|
948
|
+
* is missing. This runs once per session-dir scan, before the main `*.jsonl`
|
|
949
|
+
* glob, so a crash between the two renames in the EPERM-rewrite path does not
|
|
950
|
+
* leave the user's last good state stranded outside the loader's view.
|
|
951
|
+
*
|
|
952
|
+
* Exported for testing.
|
|
953
|
+
*/
|
|
954
|
+
export async function recoverOrphanedBackups(sessionDir: string, storage: SessionStorage): Promise<void> {
|
|
955
|
+
let backups: string[];
|
|
956
|
+
try {
|
|
957
|
+
backups = storage.listFilesSync(sessionDir, "*.bak");
|
|
958
|
+
} catch {
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (backups.length === 0) return;
|
|
962
|
+
// For each primary path, pick the newest backup (highest mtime) as the recovery source.
|
|
963
|
+
const candidates = new Map<string, { backup: string; mtimeMs: number }>();
|
|
964
|
+
for (const backup of backups) {
|
|
965
|
+
const name = path.basename(backup);
|
|
966
|
+
// Expect "<primary>.<snowflake>.bak" where <primary> ends in ".jsonl".
|
|
967
|
+
if (!name.endsWith(".bak")) continue;
|
|
968
|
+
const trimmed = name.slice(0, -".bak".length);
|
|
969
|
+
const dotIdx = trimmed.lastIndexOf(".");
|
|
970
|
+
if (dotIdx <= 0) continue;
|
|
971
|
+
const primaryName = trimmed.slice(0, dotIdx);
|
|
972
|
+
if (!primaryName.endsWith(".jsonl")) continue;
|
|
973
|
+
const primaryPath = path.join(sessionDir, primaryName);
|
|
974
|
+
let mtimeMs = 0;
|
|
975
|
+
try {
|
|
976
|
+
mtimeMs = storage.statSync(backup).mtimeMs;
|
|
977
|
+
} catch {
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const existing = candidates.get(primaryPath);
|
|
981
|
+
if (!existing || mtimeMs > existing.mtimeMs) {
|
|
982
|
+
candidates.set(primaryPath, { backup, mtimeMs });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
for (const [primaryPath, { backup }] of candidates) {
|
|
986
|
+
if (storage.existsSync(primaryPath)) continue;
|
|
987
|
+
try {
|
|
988
|
+
await storage.rename(backup, primaryPath);
|
|
989
|
+
logger.warn("Recovered orphaned session backup", {
|
|
990
|
+
sessionFile: primaryPath,
|
|
991
|
+
backupPath: backup,
|
|
992
|
+
});
|
|
993
|
+
} catch (err) {
|
|
994
|
+
logger.warn("Failed to recover orphaned session backup", {
|
|
995
|
+
sessionFile: primaryPath,
|
|
996
|
+
backupPath: backup,
|
|
997
|
+
error: toError(err).message,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
944
1003
|
/**
|
|
945
1004
|
* Reads all session files from the directory and returns them sorted by mtime (newest first).
|
|
946
1005
|
* Uses low-level file I/O to efficiently read only the first 4KB of each file
|
|
947
1006
|
* to extract the JSON header and first user message without loading entire session logs into memory.
|
|
948
1007
|
*/
|
|
949
1008
|
async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
|
|
1009
|
+
await recoverOrphanedBackups(sessionDir, storage);
|
|
950
1010
|
try {
|
|
951
1011
|
const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
|
|
952
1012
|
const sessions: RecentSessionInfo[] = [];
|
|
@@ -2146,7 +2206,64 @@ export class SessionManager {
|
|
|
2146
2206
|
{ ignoreError: true },
|
|
2147
2207
|
);
|
|
2148
2208
|
}
|
|
2209
|
+
// Windows can reject overwrite-style rename with EPERM even after our own writer is closed.
|
|
2210
|
+
// Move the old session file aside first so a failed retry can roll back to the last good file.
|
|
2211
|
+
// The backup uses a plain `<basename>.<snowflake>.bak` name (no leading dot) so that if the
|
|
2212
|
+
// process crashes between the two renames, `recoverOrphanedBackups` can find it via the
|
|
2213
|
+
// shared `*.bak` glob on both real and in-memory storage backends and promote it back to
|
|
2214
|
+
// the primary on the next session-dir scan.
|
|
2149
2215
|
|
|
2216
|
+
async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise<void> {
|
|
2217
|
+
const dir = path.resolve(targetPath, "..");
|
|
2218
|
+
const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
|
|
2219
|
+
try {
|
|
2220
|
+
await this.storage.rename(targetPath, backupPath);
|
|
2221
|
+
} catch (err) {
|
|
2222
|
+
if (isEnoent(err)) {
|
|
2223
|
+
await this.storage.rename(tempPath, targetPath);
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
throw toError(renameError);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
try {
|
|
2230
|
+
await this.storage.rename(tempPath, targetPath);
|
|
2231
|
+
} catch (err) {
|
|
2232
|
+
const replaceError = toError(err);
|
|
2233
|
+
const originalError = toError(renameError);
|
|
2234
|
+
try {
|
|
2235
|
+
await this.storage.rename(backupPath, targetPath);
|
|
2236
|
+
} catch (rollbackErr) {
|
|
2237
|
+
const rollbackError = toError(rollbackErr);
|
|
2238
|
+
throw new Error(
|
|
2239
|
+
`Failed to replace session file after EPERM (original: ${originalError.message}; retry: ${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
|
|
2240
|
+
{ cause: originalError },
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
throw replaceError;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
try {
|
|
2247
|
+
await this.storage.unlink(backupPath);
|
|
2248
|
+
} catch (err) {
|
|
2249
|
+
if (!isEnoent(err)) {
|
|
2250
|
+
logger.warn("Failed to remove session rewrite backup", {
|
|
2251
|
+
sessionFile: targetPath,
|
|
2252
|
+
backupPath,
|
|
2253
|
+
error: toError(err).message,
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
async #replaceSessionFile(tempPath: string, targetPath: string): Promise<void> {
|
|
2260
|
+
try {
|
|
2261
|
+
await this.storage.rename(tempPath, targetPath);
|
|
2262
|
+
} catch (err) {
|
|
2263
|
+
if (!hasFsCode(err, "EPERM")) throw toError(err);
|
|
2264
|
+
await this.#replaceSessionFileAfterEperm(tempPath, targetPath, err);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2150
2267
|
async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
|
|
2151
2268
|
if (!this.#sessionFile) return;
|
|
2152
2269
|
const dir = path.resolve(this.#sessionFile, "..");
|
|
@@ -2159,7 +2276,7 @@ export class SessionManager {
|
|
|
2159
2276
|
await writer.flush();
|
|
2160
2277
|
await writer.fsync();
|
|
2161
2278
|
await writer.close();
|
|
2162
|
-
await this
|
|
2279
|
+
await this.#replaceSessionFile(tempPath, this.#sessionFile);
|
|
2163
2280
|
} catch (err) {
|
|
2164
2281
|
try {
|
|
2165
2282
|
await writer.close();
|
|
@@ -3191,6 +3308,7 @@ export class SessionManager {
|
|
|
3191
3308
|
): Promise<SessionInfo[]> {
|
|
3192
3309
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3193
3310
|
try {
|
|
3311
|
+
await recoverOrphanedBackups(dir, storage);
|
|
3194
3312
|
const files = storage.listFilesSync(dir, "*.jsonl");
|
|
3195
3313
|
return await collectSessionsFromFiles(files, storage);
|
|
3196
3314
|
} catch {
|
|
@@ -72,7 +72,16 @@ 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;
|
|
76
|
+
// Capture state BEFORE the call: when plan mode is already active,
|
|
77
|
+
// handlePlanModeCommand may exit it (on confirmed exit) or leave it on (on cancel
|
|
78
|
+
// or warning). In every "already active" case the typed args are NOT consumed,
|
|
79
|
+
// so preserve them in history regardless of the user's confirm/cancel choice.
|
|
80
|
+
const wasPlanModeEnabled = runtime.ctx.planModeEnabled;
|
|
75
81
|
await runtime.ctx.handlePlanModeCommand(command.args || undefined);
|
|
82
|
+
if (hadArgs && wasPlanModeEnabled) {
|
|
83
|
+
runtime.ctx.editor.addToHistory(command.text);
|
|
84
|
+
}
|
|
76
85
|
runtime.ctx.editor.setText("");
|
|
77
86
|
},
|
|
78
87
|
},
|
|
@@ -90,7 +99,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
90
99
|
inlineHint: "[objective]",
|
|
91
100
|
allowArgs: true,
|
|
92
101
|
handleTui: async (command, runtime) => {
|
|
102
|
+
const hadArgs = !!command.args;
|
|
103
|
+
// Capture state BEFORE the call (see /plan above for rationale).
|
|
104
|
+
const wasGoalModeEnabled = runtime.ctx.goalModeEnabled;
|
|
93
105
|
await runtime.ctx.handleGoalModeCommand(command.args || undefined);
|
|
106
|
+
if (hadArgs && wasGoalModeEnabled) {
|
|
107
|
+
runtime.ctx.editor.addToHistory(command.text);
|
|
108
|
+
}
|
|
94
109
|
runtime.ctx.editor.setText("");
|
|
95
110
|
},
|
|
96
111
|
},
|
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()) {
|