@gajae-code/coding-agent 0.11.9 → 0.11.10

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 (59) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/types/config/model-registry.d.ts +2 -0
  3. package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
  4. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  5. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  6. package/dist/types/sdk/bus/index.d.ts +8 -1
  7. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  8. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -7
  9. package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
  10. package/dist/types/sdk/session.d.ts +7 -0
  11. package/dist/types/session/agent-session.d.ts +2 -0
  12. package/dist/types/session/internal/managed-session-scope.d.ts +1 -1
  13. package/dist/types/task/provider-retry-status.d.ts +13 -0
  14. package/dist/types/task/types.d.ts +3 -0
  15. package/dist/types/tools/index.d.ts +3 -1
  16. package/package.json +8 -8
  17. package/scripts/generate-sdk-operation-inventory.ts +1 -0
  18. package/src/cli/notify-cli.ts +1 -0
  19. package/src/config/model-profiles.ts +7 -7
  20. package/src/config/model-registry.ts +4 -2
  21. package/src/eval/js/tool-bridge.ts +1 -1
  22. package/src/gjc-runtime/deep-interview-draft.ts +2 -1
  23. package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
  24. package/src/gjc-runtime/state-writer.ts +1 -1
  25. package/src/hooks/skill-state.ts +23 -1
  26. package/src/internal-urls/docs-index.generated.ts +3 -3
  27. package/src/modes/controllers/selector-controller.ts +38 -8
  28. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  29. package/src/prompts/agents/architect.md +8 -1
  30. package/src/prompts/agents/critic.md +8 -1
  31. package/src/prompts/agents/planner.md +8 -1
  32. package/src/sdk/broker/identity.ts +22 -12
  33. package/src/sdk/bus/daemon-paths.ts +2 -0
  34. package/src/sdk/bus/index.ts +60 -5
  35. package/src/sdk/bus/notification-service.ts +19 -5
  36. package/src/sdk/bus/operator-runtime.ts +31 -5
  37. package/src/sdk/bus/telegram-cli.ts +6 -13
  38. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  39. package/src/sdk/bus/telegram-daemon-contract.ts +13 -7
  40. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  41. package/src/sdk/bus/telegram-daemon.ts +667 -118
  42. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  43. package/src/sdk/session.ts +12 -0
  44. package/src/session/agent-session.ts +54 -10
  45. package/src/session/internal/managed-session-scope.ts +133 -117
  46. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  47. package/src/task/executor.ts +141 -39
  48. package/src/task/provider-retry-status.ts +88 -0
  49. package/src/task/render.ts +45 -26
  50. package/src/task/types.ts +3 -0
  51. package/src/tools/bash-allowed-prefixes.ts +45 -1
  52. package/src/tools/index.ts +3 -1
  53. package/src/tools/subagent-render.ts +74 -16
  54. package/src/tools/subagent.ts +2 -0
  55. package/vendor/insane-search/MANIFEST.json +4 -1
  56. package/vendor/insane-search/engine/bias_check.py +5 -0
  57. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  58. package/vendor/insane-search/engine/transport.py +13 -2
  59. package/vendor/insane-search/engine/validators.py +4 -1
@@ -15,7 +15,7 @@ import type {
15
15
  } from "@gajae-code/agent-core";
16
16
  import { recordHandoff, resolveTelemetry } from "@gajae-code/agent-core";
17
17
  import { estimateMessageTokensHeuristic } from "@gajae-code/agent-core/compaction";
18
- import type { Message, Model, ServiceTier } from "@gajae-code/ai";
18
+ import type { AssistantMessage, Message, Model, ServiceTier } from "@gajae-code/ai";
19
19
  import { type JsonSchemaValidationIssue, validateJsonSchemaValue } from "@gajae-code/ai/utils/schema";
20
20
  import { logger, prompt, untilAborted } from "@gajae-code/utils";
21
21
  import { AsyncJobManager } from "../async";
@@ -50,6 +50,7 @@ import type { EventBus } from "../utils/event-bus";
50
50
  import { buildNamedToolChoiceResult } from "../utils/tool-choice";
51
51
  import type { WorkspaceTree } from "../workspace-tree";
52
52
  import { validateAllocatedTaskId } from "./id";
53
+ import { classifyProviderRetry, providerNameFromModel } from "./provider-retry-status";
53
54
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
54
55
  import { persistTaskTokenLog, taskTokenLogFromUsage } from "./token-log";
55
56
  import {
@@ -81,6 +82,21 @@ const agentEventTypes = new Set<AgentEvent["type"]>([
81
82
  "tool_execution_end",
82
83
  ]);
83
84
 
85
+ const providerStreamingUpdateTypes = new Set<string>([
86
+ "text_start",
87
+ "text_delta",
88
+ "text_end",
89
+ "thinking_start",
90
+ "thinking_delta",
91
+ "thinking_end",
92
+ "reasoning_summary_start",
93
+ "reasoning_summary_delta",
94
+ "reasoning_summary_end",
95
+ "toolcall_start",
96
+ "toolcall_delta",
97
+ "toolcall_end",
98
+ ]);
99
+
84
100
  const isAgentEvent = (event: AgentSessionEvent): event is AgentEvent =>
85
101
  agentEventTypes.has(event.type as AgentEvent["type"]);
86
102
 
@@ -845,7 +861,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
845
861
  let modelSubstitutionWarning: ModelSubstitutionWarning | undefined;
846
862
  let resolvedModelString: string | undefined;
847
863
  let lastAssistantModelString: string | undefined;
864
+ let activeProviderModelString: string | undefined;
848
865
  let effectiveThinkingLevelForWarning: ThinkingLevel | undefined;
866
+ let lastProviderProgressAtMs: number | undefined;
867
+ let sessionEventOrdinal = 0;
868
+ let retryStartOrdinal = 0;
869
+ const seenAssistantMessages = new WeakSet<AgentMessage>();
870
+ const seenAssistantMessageIdentities = new Set<string>();
849
871
 
850
872
  // Accumulate usage incrementally from message_end events (no memory for streaming events)
851
873
  const accumulatedUsage = {
@@ -927,7 +949,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
927
949
 
928
950
  const emitProgressNow = () => {
929
951
  progress.durationMs = Date.now() - startTime;
930
- onProgress?.({ ...progress });
952
+ const progressSnapshot = structuredClone(progress);
953
+ onProgress?.(progressSnapshot);
931
954
  if (options.eventBus) {
932
955
  options.eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, {
933
956
  index,
@@ -935,7 +958,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
935
958
  agentSource: agent.source,
936
959
  task,
937
960
  assignment,
938
- progress: { ...progress },
961
+ progress: progressSnapshot,
939
962
  sessionFile: null,
940
963
  });
941
964
  }
@@ -990,6 +1013,79 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
990
1013
  : undefined;
991
1014
  };
992
1015
 
1016
+ const getAssistantMessageIdentity = (message: AgentMessage): string | undefined => {
1017
+ if (message.role !== "assistant") return undefined;
1018
+ const model = getMessageModelString(message);
1019
+ const timestamp = "timestamp" in message ? message.timestamp : undefined;
1020
+ if (!model || typeof timestamp !== "number") return undefined;
1021
+ const responseId = "responseId" in message && typeof message.responseId === "string" ? message.responseId : "";
1022
+ return JSON.stringify([model, timestamp, responseId]);
1023
+ };
1024
+
1025
+ const rememberAssistantMessage = (message: AgentMessage): void => {
1026
+ seenAssistantMessages.add(message);
1027
+ const identity = getAssistantMessageIdentity(message);
1028
+ if (identity) seenAssistantMessageIdentities.add(identity);
1029
+ };
1030
+
1031
+ const isProviderAssistantMessage = (message: AgentMessage): message is AssistantMessage =>
1032
+ message.role === "assistant" &&
1033
+ "stopReason" in message &&
1034
+ Array.isArray(message.content) &&
1035
+ getMessageModelString(message) !== undefined;
1036
+
1037
+ const isSuccessfulProviderAssistantMessage = (message: AgentMessage): message is AssistantMessage =>
1038
+ isProviderAssistantMessage(message) &&
1039
+ message.stopReason !== "error" &&
1040
+ message.stopReason !== "aborted" &&
1041
+ message.errorMessage === undefined &&
1042
+ message.errorKind === undefined &&
1043
+ message.transportFailure === undefined;
1044
+
1045
+ const hasMatchingActiveProviderModel = (message: AgentMessage): message is AssistantMessage =>
1046
+ isProviderAssistantMessage(message) &&
1047
+ activeProviderModelString !== undefined &&
1048
+ getMessageModelString(message) === activeProviderModelString;
1049
+
1050
+ const isProviderStreamingUpdate = (event: Extract<AgentEvent, { type: "message_update" }>): boolean => {
1051
+ const update = event.assistantMessageEvent;
1052
+ if (!update || typeof update !== "object" || !providerStreamingUpdateTypes.has(update.type)) return false;
1053
+ if (!("partial" in update) || getMessageModelString(update.partial) !== activeProviderModelString) return false;
1054
+ switch (update.type) {
1055
+ case "text_delta":
1056
+ case "thinking_delta":
1057
+ case "reasoning_summary_delta":
1058
+ case "toolcall_delta":
1059
+ return typeof update.delta === "string";
1060
+ default:
1061
+ return typeof update.contentIndex === "number";
1062
+ }
1063
+ };
1064
+
1065
+ const getProviderTextDelta = (event: Extract<AgentEvent, { type: "message_update" }>): string | undefined => {
1066
+ const update = (event as { assistantMessageEvent?: unknown }).assistantMessageEvent;
1067
+ if (!update || typeof update !== "object") return undefined;
1068
+ const record = update as { type?: unknown; delta?: unknown };
1069
+ return record.type === "text_delta" && typeof record.delta === "string" ? record.delta : undefined;
1070
+ };
1071
+
1072
+ const isProviderBackedRecoveryEvent = (
1073
+ event: Extract<AgentEvent, { type: "message_start" | "message_update" }>,
1074
+ eventOrdinal: number,
1075
+ ): boolean => {
1076
+ const messageIdentity = getAssistantMessageIdentity(event.message);
1077
+ if (
1078
+ !progress.retryState ||
1079
+ eventOrdinal <= retryStartOrdinal ||
1080
+ seenAssistantMessages.has(event.message) ||
1081
+ (messageIdentity !== undefined && seenAssistantMessageIdentities.has(messageIdentity))
1082
+ )
1083
+ return false;
1084
+ if (!hasMatchingActiveProviderModel(event.message)) return false;
1085
+ if (event.type === "message_start") return isSuccessfulProviderAssistantMessage(event.message);
1086
+ return isProviderStreamingUpdate(event);
1087
+ };
1088
+
993
1089
  const updateRecentOutputLines = () => {
994
1090
  const lines = recentOutputTail.split("\n").filter(line => line.trim());
995
1091
  progress.recentOutput = lines.slice(-8).reverse();
@@ -1004,21 +1100,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1004
1100
  updateRecentOutputLines();
1005
1101
  };
1006
1102
 
1007
- const replaceRecentOutputFromContent = (content: unknown[]) => {
1008
- recentOutputTail = "";
1009
- for (const block of content) {
1010
- if (!block || typeof block !== "object") continue;
1011
- const record = block as { type?: unknown; text?: unknown };
1012
- if (record.type !== "text" || typeof record.text !== "string") continue;
1013
- if (!record.text) continue;
1014
- recentOutputTail += record.text;
1015
- if (recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES) {
1016
- recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
1017
- }
1018
- }
1019
- updateRecentOutputLines();
1020
- };
1021
-
1022
1103
  const resetRecentOutput = () => {
1023
1104
  recentOutputTail = "";
1024
1105
  progress.recentOutput = [];
@@ -1038,20 +1119,32 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1038
1119
  });
1039
1120
  };
1040
1121
 
1041
- const processEvent = (event: AgentEvent) => {
1122
+ const processEvent = (event: AgentEvent, eventOrdinal: number) => {
1042
1123
  if (resolved) return;
1043
1124
 
1044
1125
  forwardSubagentEvent(event);
1045
1126
 
1046
1127
  const now = Date.now();
1047
1128
  let flushProgress = false;
1129
+ // A retry is recovered at the first assistant provider event, before the
1130
+ // session emits auto_retry_end after message completion.
1048
1131
 
1049
1132
  switch (event.type) {
1050
- case "message_start":
1051
- if (event.message?.role === "assistant") {
1133
+ case "message_start": {
1134
+ if (event.message.role !== "assistant") break;
1135
+ const retryWasActive = Boolean(progress.retryState);
1136
+ const recoversRetry = isProviderBackedRecoveryEvent(event, eventOrdinal);
1137
+ rememberAssistantMessage(event.message);
1138
+ if (recoversRetry || !retryWasActive) {
1139
+ lastProviderProgressAtMs = now;
1052
1140
  resetRecentOutput();
1053
1141
  }
1142
+ if (recoversRetry) {
1143
+ progress.retryState = undefined;
1144
+ flushProgress = true;
1145
+ }
1054
1146
  break;
1147
+ }
1055
1148
 
1056
1149
  case "tool_execution_start": {
1057
1150
  progress.toolCount++;
@@ -1163,24 +1256,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1163
1256
  }
1164
1257
 
1165
1258
  case "message_update": {
1166
- if (event.message?.role !== "assistant") break;
1167
- const assistantEvent = (
1168
- event as AgentEvent & {
1169
- assistantMessageEvent?: { type?: string; delta?: string };
1170
- }
1171
- ).assistantMessageEvent;
1172
- if (assistantEvent?.type === "text_delta" && typeof assistantEvent.delta === "string") {
1173
- appendRecentOutputTail(assistantEvent.delta);
1174
- break;
1175
- }
1176
- if (assistantEvent && assistantEvent.type !== "text_delta") {
1177
- break;
1178
- }
1179
- const updateContent =
1180
- getMessageContent(event.message) || (event as AgentEvent & { content?: unknown }).content;
1181
- if (updateContent && Array.isArray(updateContent)) {
1182
- replaceRecentOutputFromContent(updateContent);
1259
+ if (event.message.role !== "assistant") break;
1260
+ const retryWasActive = Boolean(progress.retryState);
1261
+ const recoversRetry = isProviderBackedRecoveryEvent(event, eventOrdinal);
1262
+ if (!retryWasActive) rememberAssistantMessage(event.message);
1263
+ if (recoversRetry || !retryWasActive) lastProviderProgressAtMs = now;
1264
+ if (recoversRetry) {
1265
+ progress.retryState = undefined;
1266
+ flushProgress = true;
1183
1267
  }
1268
+ const textDelta = getProviderTextDelta(event);
1269
+ if (textDelta !== undefined) appendRecentOutputTail(textDelta);
1184
1270
  break;
1185
1271
  }
1186
1272
 
@@ -1188,6 +1274,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1188
1274
  // Extract text from assistant and toolResult messages (not user prompts)
1189
1275
  const role = event.message?.role;
1190
1276
  if (role === "assistant") {
1277
+ if (isSuccessfulProviderAssistantMessage(event.message)) lastProviderProgressAtMs = now;
1191
1278
  const messageContent =
1192
1279
  getMessageContent(event.message) || (event as AgentEvent & { content?: unknown }).content;
1193
1280
  if (messageContent && Array.isArray(messageContent)) {
@@ -1200,6 +1287,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1200
1287
  const assistantModel = getMessageModelString(event.message);
1201
1288
  if (assistantModel) {
1202
1289
  lastAssistantModelString = assistantModel;
1290
+ activeProviderModelString = assistantModel;
1203
1291
  if (resolvedModelString && assistantModel !== resolvedModelString && !modelSubstitutionWarning) {
1204
1292
  modelSubstitutionWarning = {
1205
1293
  requested: resolvedModelString,
@@ -1367,6 +1455,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1367
1455
  );
1368
1456
  if (model) {
1369
1457
  resolvedModelString = formatModelString(model);
1458
+ activeProviderModelString = resolvedModelString;
1370
1459
  }
1371
1460
  if (authFallbackUsed && model && requestedModel) {
1372
1461
  modelSubstitutionWarning = {
@@ -1504,6 +1593,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1504
1593
  ? { requestedModel, reason: modelSubstitutionWarning.reason }
1505
1594
  : undefined,
1506
1595
  toolNames,
1596
+ alwaysActiveToolNames: ircEnabled ? ["irc"] : undefined,
1507
1597
  outputSchema,
1508
1598
  requireYieldTool: true,
1509
1599
  contextFiles: options.contextFiles,
@@ -1737,11 +1827,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1737
1827
 
1738
1828
  const MAX_YIELD_RETRIES = 3;
1739
1829
  unsubscribe = session.subscribe(event => {
1830
+ sessionEventOrdinal += 1;
1831
+ const eventOrdinal = sessionEventOrdinal;
1740
1832
  if (event.type === "auto_retry_start") {
1833
+ retryStartOrdinal = eventOrdinal;
1834
+ for (const message of session.messages) {
1835
+ if (message.role === "assistant") rememberAssistantMessage(message);
1836
+ }
1741
1837
  progress.retryState = {
1742
1838
  attempt: event.attempt,
1743
1839
  maxAttempts: event.maxAttempts,
1744
1840
  unbounded: event.unbounded,
1841
+ kind: classifyProviderRetry(event.errorMessage),
1842
+ provider: providerNameFromModel(
1843
+ activeProviderModelString ?? lastAssistantModelString ?? resolvedModelString,
1844
+ ),
1845
+ lastProviderProgressAtMs,
1745
1846
  delayMs: event.delayMs,
1746
1847
  errorMessage: event.errorMessage,
1747
1848
  startedAtMs: Date.now(),
@@ -1763,12 +1864,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1763
1864
  return;
1764
1865
  }
1765
1866
  if (event.type === "model_fallback_switched") {
1867
+ activeProviderModelString = event.to;
1766
1868
  forwardSubagentEvent(event);
1767
1869
  return;
1768
1870
  }
1769
1871
  if (isAgentEvent(event)) {
1770
1872
  try {
1771
- processEvent(event);
1873
+ processEvent(event, eventOrdinal);
1772
1874
  } catch (err) {
1773
1875
  logger.error("Subagent event processing failed", {
1774
1876
  error: err instanceof Error ? err.message : String(err),
@@ -0,0 +1,88 @@
1
+ import type { AgentProgress, TaskToolDetails } from "./types";
2
+
3
+ export type ProviderRetryKind = NonNullable<AgentProgress["retryState"]>["kind"];
4
+
5
+ const FIRST_EVENT_TIMEOUT_PATTERN = /stream timed out while waiting for the first event/i;
6
+ const IDLE_STREAM_STALL_PATTERN = /stream stalled while waiting for the next event/i;
7
+
8
+ export function classifyProviderRetry(errorMessage: string): ProviderRetryKind {
9
+ if (FIRST_EVENT_TIMEOUT_PATTERN.test(errorMessage)) return "first_event_timeout";
10
+ if (IDLE_STREAM_STALL_PATTERN.test(errorMessage)) return "idle_stream_stall";
11
+ return "provider_error";
12
+ }
13
+
14
+ export function providerNameFromModel(model: string | undefined): string | undefined {
15
+ const provider = model?.split("/", 1)[0]?.trim();
16
+ return provider || undefined;
17
+ }
18
+
19
+ export function providerRetryPhaseLabel(kind: ProviderRetryKind): string {
20
+ switch (kind) {
21
+ case "first_event_timeout":
22
+ return "first event timeout";
23
+ case "idle_stream_stall":
24
+ return "stream stalled";
25
+ case "provider_error":
26
+ return "provider error";
27
+ }
28
+ }
29
+
30
+ export function providerProgressAgeLabel(retryState: NonNullable<AgentProgress["retryState"]>, nowMs: number): string {
31
+ if (retryState.lastProviderProgressAtMs === undefined) return "no provider events yet";
32
+ const ageSeconds = Math.max(0, Math.floor((nowMs - retryState.lastProviderProgressAtMs) / 1000));
33
+ return `last provider progress ${ageSeconds}s ago`;
34
+ }
35
+
36
+ function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return Boolean(value) && typeof value === "object";
38
+ }
39
+
40
+ function isTaskToolDetails(value: unknown): value is TaskToolDetails {
41
+ return (
42
+ isRecord(value) && Array.isArray(value.results) && (value.progress === undefined || Array.isArray(value.progress))
43
+ );
44
+ }
45
+
46
+ function isAgentProgress(value: unknown): value is AgentProgress {
47
+ return isRecord(value) && typeof value.status === "string";
48
+ }
49
+
50
+ export function hasActiveProviderRetryInTaskDetails(details: TaskToolDetails, seen = new WeakSet<object>()): boolean {
51
+ if (!isTaskToolDetails(details) || seen.has(details)) return false;
52
+ seen.add(details);
53
+ for (const progress of details.progress ?? []) {
54
+ if (isAgentProgress(progress) && hasActiveProviderRetryInProgress(progress, seen)) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ export function hasActiveProviderRetryInProgress(progress: AgentProgress, seen = new WeakSet<object>()): boolean {
60
+ if (!isAgentProgress(progress) || seen.has(progress)) return false;
61
+ seen.add(progress);
62
+ if (progress.status === "running" && progress.retryState) return true;
63
+
64
+ const nestedTaskData = isRecord(progress.extractedToolData) ? progress.extractedToolData.task : undefined;
65
+ if (Array.isArray(nestedTaskData)) {
66
+ for (const nestedDetails of nestedTaskData) {
67
+ if (hasActiveProviderRetryInTaskDetails(nestedDetails as TaskToolDetails, seen)) return true;
68
+ }
69
+ }
70
+ return hasActiveProviderRetryInTaskDetails(progress.inflightTaskDetails as TaskToolDetails, seen);
71
+ }
72
+
73
+ export interface ProviderDegradationGroup {
74
+ provider: string;
75
+ count: number;
76
+ }
77
+
78
+ export function collectProviderDegradationGroups(progress: readonly AgentProgress[]): ProviderDegradationGroup[] {
79
+ const counts = new Map<string, number>();
80
+ for (const item of progress) {
81
+ if (!isAgentProgress(item) || item.status !== "running" || !item.retryState) continue;
82
+ const provider = item.retryState.provider ?? "provider";
83
+ counts.set(provider, (counts.get(provider) ?? 0) + 1);
84
+ }
85
+ return Array.from(counts, ([provider, count]) => ({ provider, count }))
86
+ .filter(group => group.count > 1)
87
+ .sort((a, b) => b.count - a.count || a.provider.localeCompare(b.provider));
88
+ }
@@ -28,6 +28,12 @@ import {
28
28
  type SubmitReviewDetails,
29
29
  } from "../tools/review";
30
30
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine } from "../tui";
31
+ import {
32
+ collectProviderDegradationGroups,
33
+ hasActiveProviderRetryInTaskDetails,
34
+ providerProgressAgeLabel,
35
+ providerRetryPhaseLabel,
36
+ } from "./provider-retry-status";
31
37
  import type { TaskResultReceipt } from "./receipt";
32
38
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
33
39
  import type { AgentProgress, TaskParams, TaskToolDetails } from "./types";
@@ -123,6 +129,15 @@ function formatModelSubstitutionWarning(warning: { requested: string; effective:
123
129
  return `Requested model substituted: ${warning.requested} -> ${warning.effective}`;
124
130
  }
125
131
 
132
+ function renderProviderDegradationNotices(progress: readonly AgentProgress[], theme: Theme): string[] {
133
+ return collectProviderDegradationGroups(progress).map(group =>
134
+ theme.fg(
135
+ "warning",
136
+ truncateToWidth(replaceTabs(`provider degraded: ${group.count} subagents retrying on ${group.provider}`), 100),
137
+ ),
138
+ );
139
+ }
140
+
126
141
  function formatJsonScalar(value: unknown, _theme: Theme): string {
127
142
  if (value === null) return "null";
128
143
  if (typeof value === "string") {
@@ -548,14 +563,11 @@ function renderAgentProgress(
548
563
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
549
564
  let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
550
565
 
551
- // Show retry-blocked badge so the parent immediately sees that a child
552
- // is sleeping on a provider 429, not silently progressing. Wins over the
553
- // generic running spinner because "we're waiting on a quota window" is
554
- // the operationally meaningful state.
566
+ // A provider recovery loop is operationally distinct from normal agent work.
555
567
  if (progress.retryState && progress.status === "running") {
556
- statusLine += ` ${formatBadge("retrying", "warning", theme)}`;
568
+ statusLine += ` ${formatBadge("provider degraded", "warning", theme)}`;
557
569
  } else if (progress.retryFailure && (progress.status === "failed" || progress.status === "aborted")) {
558
- statusLine += ` ${formatBadge("rate-limited", "error", theme)}`;
570
+ statusLine += ` ${formatBadge("provider failed", "error", theme)}`;
559
571
  } else if (progress.status === "failed" || progress.status === "aborted") {
560
572
  const statusLabel = progress.status === "failed" ? "failed" : "aborted";
561
573
  statusLine += ` ${formatBadge(statusLabel, iconColor, theme)}`;
@@ -615,8 +627,10 @@ function renderAgentProgress(
615
627
  // keep spinning while a child sleeps on a 3-hour provider rate-limit.
616
628
  if (progress.retryState && progress.status === "running") {
617
629
  const attemptLabel = progress.retryState.unbounded
618
- ? `attempt ${progress.retryState.attempt}`
619
- : `${progress.retryState.attempt}/${progress.retryState.maxAttempts}`;
630
+ ? `attempt ${progress.retryState.attempt}, unbounded`
631
+ : `attempt ${progress.retryState.attempt} of ${progress.retryState.maxAttempts}, bounded`;
632
+ const phaseLabel = providerRetryPhaseLabel(progress.retryState.kind);
633
+ const progressAge = staticTime ? "" : ` · ${providerProgressAgeLabel(progress.retryState, Date.now())}`;
620
634
  // `staticTime` omits the wall-clock countdown so a cached await body stays a
621
635
  // pure function of its key (the producer already drops time-only churn).
622
636
  let waitLabel = "";
@@ -624,7 +638,7 @@ function renderAgentProgress(
624
638
  const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - Date.now());
625
639
  waitLabel = remainingMs > 0 ? ` in ${formatDuration(remainingMs)}` : " now";
626
640
  }
627
- const summary = `retrying ${attemptLabel}${waitLabel}: ${truncateToWidth(replaceTabs(progress.retryState.errorMessage), 60)}`;
641
+ const summary = `${phaseLabel} · retrying ${attemptLabel}${waitLabel}${progressAge}: ${truncateToWidth(replaceTabs(progress.retryState.errorMessage), 60)}`;
628
642
  lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("warning", summary)}`);
629
643
  } else if (progress.retryFailure && progress.status !== "running") {
630
644
  const summary = `auto-retry gave up after ${progress.retryFailure.attempt} attempt${
@@ -953,23 +967,28 @@ export function renderResult(
953
967
  .u32(spinnerFrame ?? 0)
954
968
  .u32(width)
955
969
  .digest();
956
- if (cached?.key === key) return cached.lines;
970
+ const hasDynamicRetry = hasActiveProviderRetryInTaskDetails(details);
971
+ if (!hasDynamicRetry && cached?.key === key) return cached.lines;
957
972
 
958
973
  const lines: string[] = [];
959
974
 
960
- const shouldRenderProgress =
961
- Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
975
+ const hasProgress = Boolean(details.progress && details.progress.length > 0);
976
+ const shouldRenderProgress = hasProgress && (isPartial || details.results.length === 0);
977
+ const hasResults = details.results.length > 0;
978
+ if (hasResults) {
979
+ details.results.forEach((res, i) => {
980
+ const isLast = !shouldRenderProgress && i === details.results.length - 1;
981
+ lines.push(...renderAgentResult(res, isLast, expanded, theme));
982
+ });
983
+ }
962
984
  if (shouldRenderProgress && details.progress) {
985
+ lines.push(...renderProviderDegradationNotices(details.progress, theme));
963
986
  details.progress.forEach((progress, i) => {
964
987
  const isLast = i === details.progress!.length - 1;
965
988
  lines.push(...renderAgentProgress(progress, isLast, expanded, theme, spinnerFrame));
966
989
  });
967
- } else if (details.results && details.results.length > 0) {
968
- details.results.forEach((res, i) => {
969
- const isLast = i === details.results.length - 1;
970
- lines.push(...renderAgentResult(res, isLast, expanded, theme));
971
- });
972
-
990
+ }
991
+ if (hasResults && !shouldRenderProgress) {
973
992
  const abortedCount = details.results.filter(r => r.status === "aborted").length;
974
993
  const mergeFailedCount = details.results.filter(r => r.status === "merge_failed").length;
975
994
  const successCount = details.results.filter(r => r.status === "completed").length;
@@ -997,7 +1016,7 @@ export function renderResult(
997
1016
  if (lines.length === 0) {
998
1017
  const text = fallbackText.trim() ? fallbackText : "No results";
999
1018
  const result = [theme.fg("dim", truncateToWidth(text, width))];
1000
- cached = { key, lines: result };
1019
+ if (!hasDynamicRetry) cached = { key, lines: result };
1001
1020
  return result;
1002
1021
  }
1003
1022
 
@@ -1019,9 +1038,9 @@ export function renderResult(
1019
1038
  }
1020
1039
 
1021
1040
  const indented = lines.map(line =>
1022
- line.length > 0 ? truncateToWidth(` ${line}`, width, Ellipsis.Omit) : "",
1041
+ line.length > 0 ? truncateToWidth(` ${replaceTabs(line)}`, width, Ellipsis.Omit) : "",
1023
1042
  );
1024
- cached = { key, lines: indented };
1043
+ if (!hasDynamicRetry) cached = { key, lines: indented };
1025
1044
  return indented;
1026
1045
  },
1027
1046
  invalidate() {
@@ -1065,16 +1084,16 @@ function renderNestedTaskTree(
1065
1084
  ): string[] {
1066
1085
  const lines: string[] = [];
1067
1086
  for (const details of detailsList) {
1068
- const hasResults = Boolean(details.results && details.results.length > 0);
1069
- if (hasResults) {
1087
+ const inflight = details.progress;
1088
+ const hasInflight = Boolean(inflight && inflight.length > 0);
1089
+ if (details.results && details.results.length > 0) {
1070
1090
  details.results.forEach((result, index) => {
1071
- const isLast = index === details.results.length - 1;
1091
+ const isLast = !hasInflight && index === details.results.length - 1;
1072
1092
  lines.push(...renderAgentResult(result, isLast, expanded, theme));
1073
1093
  });
1074
- continue;
1075
1094
  }
1076
- const inflight = details.progress;
1077
1095
  if (inflight && inflight.length > 0) {
1096
+ lines.push(...renderProviderDegradationNotices(inflight, theme));
1078
1097
  inflight.forEach((prog, index) => {
1079
1098
  const isLast = index === inflight.length - 1;
1080
1099
  lines.push(...renderAgentProgress(prog, isLast, expanded, theme, spinnerFrame, staticTime));
package/src/task/types.ts CHANGED
@@ -285,6 +285,9 @@ export interface AgentProgress {
285
285
  attempt: number;
286
286
  maxAttempts: number;
287
287
  unbounded?: boolean;
288
+ kind: "first_event_timeout" | "idle_stream_stall" | "provider_error";
289
+ provider?: string;
290
+ lastProviderProgressAtMs?: number;
288
291
  delayMs: number;
289
292
  errorMessage: string;
290
293
  startedAtMs: number;
@@ -18,7 +18,14 @@ export interface BashRestrictionOptions {
18
18
  }
19
19
 
20
20
  const SHELL_CONTROL_CHARS = new Set([";", "|", "&", "<", ">", "(", ")"]);
21
- const UNSAFE_UNQUOTED_EXPANSION_CHARS = new Set(["$", "*", "?", "[", "]", "{", "}", "~"]);
21
+ // `~` is handled separately: bash performs tilde expansion only at positions where a
22
+ // tilde can open an expansion - the start of a word, and inside an assignment word
23
+ // directly after the first `=` or after a `:` in the assigned value. A tilde anywhere
24
+ // else (e.g. the git revision `HEAD~1`) is a literal character.
25
+ const UNSAFE_UNQUOTED_EXPANSION_CHARS = new Set(["$", "*", "?", "[", "]", "{", "}"]);
26
+ // A bash assignment word has a plain assignment name before its first unquoted `=`,
27
+ // in either the `name=value` or the compound `name+=value` form.
28
+ const ASSIGNMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*\+?$/u;
22
29
  const ALLOWED_STATE_ACTIONS = new Set(["read", "write", "contract"]);
23
30
  const CANONICAL_STATE_TARGETS = new Set<string>(CANONICAL_GJC_WORKFLOW_SKILLS);
24
31
  const READ_ONLY_COMMANDS = new Set(["grep", "rg", "tree", "ls", "pwd", "wc", "du", "file", "stat"]);
@@ -29,6 +36,14 @@ function parseShellWords(command: string): { words: string[]; reason?: string }
29
36
  let quote: "single" | "double" | null = null;
30
37
 
31
38
  let wordStarted = false;
39
+ // Per-token tilde-expansion state. `assignmentNameCandidate` stays true while the
40
+ // characters before the first unquoted `=` could still form an assignment name (any
41
+ // quoting in that region disqualifies the token), `sawAssignmentEquals` records that
42
+ // the token is an assignment word, and `tildeExpandable` means an unquoted `~` at the
43
+ // current index would actually be expanded by bash.
44
+ let assignmentNameCandidate = true;
45
+ let sawAssignmentEquals = false;
46
+ let tildeExpandable = false;
32
47
  for (let index = 0; index < command.length; index += 1) {
33
48
  const char = command[index]!;
34
49
  const next = command[index + 1];
@@ -63,11 +78,15 @@ function parseShellWords(command: string): { words: string[]; reason?: string }
63
78
  if (char === "'") {
64
79
  quote = "single";
65
80
  wordStarted = true;
81
+ if (!sawAssignmentEquals) assignmentNameCandidate = false;
82
+ tildeExpandable = false;
66
83
  continue;
67
84
  }
68
85
  if (char === '"') {
69
86
  quote = "double";
70
87
  wordStarted = true;
88
+ if (!sawAssignmentEquals) assignmentNameCandidate = false;
89
+ tildeExpandable = false;
71
90
  continue;
72
91
  }
73
92
  if (char === "`" || (char === "$" && next === "(")) {
@@ -82,17 +101,42 @@ function parseShellWords(command: string): { words: string[]; reason?: string }
82
101
  if (UNSAFE_UNQUOTED_EXPANSION_CHARS.has(char)) {
83
102
  return { words, reason: `shell expansion character '${char}' is not allowed in restricted bash commands` };
84
103
  }
104
+ // Tilde expansion is positional: bash expands a tilde only where one can open an
105
+ // expansion, i.e. at the start of a word or, inside an assignment word, directly
106
+ // after the first `=` or after a `:` in the assigned value. A tilde anywhere else
107
+ // is literal, which is what git revision syntax such as `HEAD~1` relies on.
108
+ if (char === "~" && (!wordStarted || tildeExpandable)) {
109
+ return { words, reason: "shell expansion character '~' is not allowed in restricted bash commands" };
110
+ }
85
111
  if (/\s/u.test(char)) {
86
112
  if (wordStarted) {
87
113
  words.push(current);
88
114
  current = "";
89
115
  wordStarted = false;
90
116
  }
117
+ assignmentNameCandidate = true;
118
+ sawAssignmentEquals = false;
119
+ tildeExpandable = false;
91
120
  continue;
92
121
  }
93
122
  if (char === "\\") {
94
123
  return { words, reason: "backslash escapes are not allowed in restricted bash commands" };
95
124
  }
125
+ if (char === "=" && !sawAssignmentEquals) {
126
+ // Only a plain assignment name before the first unquoted `=` makes this an
127
+ // assignment word whose value bash scans for tilde expansions.
128
+ if (assignmentNameCandidate && ASSIGNMENT_NAME_PATTERN.test(current)) {
129
+ sawAssignmentEquals = true;
130
+ tildeExpandable = true;
131
+ } else {
132
+ assignmentNameCandidate = false;
133
+ tildeExpandable = false;
134
+ }
135
+ } else if (char === ":" && sawAssignmentEquals) {
136
+ tildeExpandable = true;
137
+ } else {
138
+ tildeExpandable = false;
139
+ }
96
140
  current += char;
97
141
  wordStarted = true;
98
142
  }
@@ -275,9 +275,11 @@ export interface ToolSession {
275
275
  /** Agent identity used for IRC routing. Returns the registry id (e.g. "0-Main", "0-AuthLoader"). */
276
276
  getAgentId?: () => string | null;
277
277
  /** Look up a registered tool by name (used by the eval js backend's tool bridge). */
278
+ getToolByName?: (name: string) => AgentTool | undefined;
279
+ /** Look up a registered tool with the session's execution guards applied. */
280
+ getToolForExecution?: (name: string) => AgentTool | undefined;
278
281
  /** Purge undelivered queued custom messages matching the predicate. Returns counts. */
279
282
  purgeQueuedCustomMessages?: (predicate: (message: CustomMessage) => boolean) => PurgeQueuedCustomMessagesResult;
280
- getToolByName?: (name: string) => AgentTool | undefined;
281
283
  /** Agent registry for IRC routing across live sessions. */
282
284
  agentRegistry?: AgentRegistry;
283
285
  /** Optional restricted bash command prefixes for read-only role agents and constrained modes. */