@mocito/pi-goal 0.1.6 → 0.1.7

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.1.7 - 2026-06-08
6
+
7
+ ### Fixed
8
+
9
+ - Stop compounding active elapsed time during token accounting; active time is now materialized only on status transitions and computed live for display.
10
+
11
+ ### Changed
12
+
13
+ - Pause goals on assistant-surfaced provider usage/quota/billing limit errors and repeated provider failures, not only HTTP 429 response hooks.
14
+
3
15
  ## 0.1.6 - 2026-06-06
4
16
 
5
17
  ### Changed
package/README.md CHANGED
@@ -44,6 +44,8 @@ When an active goal is idle, the extension injects a hidden `pi-goal-context` me
44
44
 
45
45
  The footer and optional editor widget show status, elapsed active time, token usage, and budget.
46
46
 
47
+ Provider usage-limit handling pauses active goals when Pi exposes HTTP 429 responses or assistant error messages that indicate subscription, quota, billing, balance, or repeated provider failures. This prevents automatic continuation from retrying indefinitely after provider limits such as 5-hour subscription caps.
48
+
47
49
  ## Examples
48
50
 
49
51
  Simple goal:
@@ -81,14 +83,14 @@ Environment flags:
81
83
  ## Troubleshooting
82
84
 
83
85
  - If continuation does not start, run `/goal status` and confirm the goal is `active`.
84
- - If a goal stops unexpectedly, check whether it reached its token budget or the provider returned a rate/usage limit.
86
+ - If a goal stops unexpectedly, check whether it reached its token budget or the provider returned a rate/usage limit. Usage-limit pauses may include a provider reset hint when one is available.
85
87
  - If context appears stale after tree navigation or reload, run `/goal status`; branch state is reconstructed from the active branch.
86
88
  - In print/JSON modes, commands and tools work, but interactive confirmations/editors are unavailable.
87
89
 
88
90
  ## Limitations
89
91
 
90
92
  - Token budget is enforced after finalized assistant usage is visible; v1 cannot hard-stop mid-turn.
91
- - Usage-limit handling is best-effort via HTTP 429 provider responses.
93
+ - Usage-limit handling is best-effort via HTTP responses and assistant error messages; provider transports vary in how much structured limit information they expose.
92
94
  - Automatic continuation is session-local, not a background daemon.
93
95
 
94
96
  ## Development and validation
@@ -9,17 +9,21 @@ import { registerGoalTools } from "../src/tools.js";
9
9
  import type { GoalState } from "../src/types.js";
10
10
  import { GOAL_EVENT_TYPE } from "../src/types.js";
11
11
  import { nowIso, realizedTimeUsed } from "../src/utils.js";
12
+ import { classifyAssistantError, classifyProviderLimit } from "../src/provider-limits.js";
13
+ import type { ProviderLimitClassification } from "../src/provider-limits.js";
12
14
  import { clearGoalUi, updateGoalUi } from "../src/ui.js";
13
15
 
14
16
  export default function piGoal(pi: ExtensionAPI) {
15
17
  reportInstallTelemetry();
16
18
 
17
19
  let goal: GoalState | null = null;
20
+ let consecutiveAssistantErrors = 0;
18
21
  const scheduler = new GoalContinuationScheduler(pi, { getGoal: () => goal });
19
22
 
20
23
  const setGoal = (next: GoalState | null) => { goal = next; };
21
24
 
22
25
  function afterGoalChanged(ctx: ExtensionContext, event?: string): void {
26
+ if (event) consecutiveAssistantErrors = 0;
23
27
  updateGoalUi(ctx, goal);
24
28
  if (event) {
25
29
  ctx.ui.notify(event, "info");
@@ -28,6 +32,17 @@ export default function piGoal(pi: ExtensionAPI) {
28
32
  if (!goal || goal.status !== "active") scheduler.clear();
29
33
  }
30
34
 
35
+ function pauseForProviderLimit(ctx: ExtensionContext, classification: ProviderLimitClassification): void {
36
+ if (!goal || goal.status !== "active" || !classification.pause) return;
37
+ const limited = statusMutation(goal, "usage_limited", realizedTimeUsed(goal), undefined);
38
+ appendGoalMutation(pi, limited);
39
+ goal = applyGoalMutation(goal, limited);
40
+ const suffix = classification.resetHint ? ` (${classification.resetHint})` : classification.retryAfterSeconds != null ? ` (retry after ${classification.retryAfterSeconds}s)` : "";
41
+ ctx.ui.notify(`Goal paused because the provider hit a usage/rate limit${suffix}.`, "warning");
42
+ updateGoalUi(ctx, goal);
43
+ scheduler.clear();
44
+ }
45
+
31
46
  function accountAndEnforceBudget(ctx: ExtensionContext): void {
32
47
  if (!goal) return;
33
48
  const result = accountUsageFromBranch(goal, ctx.sessionManager.getBranch() as any[]);
@@ -90,7 +105,22 @@ export default function piGoal(pi: ExtensionAPI) {
90
105
  });
91
106
 
92
107
  pi.on("message_end", async (event: any, ctx) => {
93
- if (event.message?.role === "assistant") accountAndEnforceBudget(ctx);
108
+ if (event.message?.role !== "assistant") return;
109
+ const classification = classifyAssistantError(event.message);
110
+ if (classification.pause) {
111
+ consecutiveAssistantErrors = 0;
112
+ pauseForProviderLimit(ctx, classification);
113
+ return;
114
+ }
115
+ if (event.message?.stopReason === "error" || event.message?.errorMessage) {
116
+ consecutiveAssistantErrors++;
117
+ if (consecutiveAssistantErrors >= 3 && goal?.status === "active") {
118
+ pauseForProviderLimit(ctx, { kind: "provider_error", pause: true, reason: "repeated assistant provider errors" });
119
+ }
120
+ return;
121
+ }
122
+ consecutiveAssistantErrors = 0;
123
+ accountAndEnforceBudget(ctx);
94
124
  });
95
125
 
96
126
  pi.on("turn_end", async (_event, ctx) => {
@@ -103,13 +133,8 @@ export default function piGoal(pi: ExtensionAPI) {
103
133
  });
104
134
 
105
135
  pi.on("after_provider_response", async (event, ctx) => {
106
- if (event.status !== 429 || !goal || goal.status !== "active") return;
107
- const limited = statusMutation(goal, "usage_limited", realizedTimeUsed(goal), undefined);
108
- appendGoalMutation(pi, limited);
109
- goal = applyGoalMutation(goal, limited);
110
- ctx.ui.notify("Goal paused because the provider returned a usage/rate limit.", "warning");
111
- updateGoalUi(ctx, goal);
112
- scheduler.clear();
136
+ if (!goal || goal.status !== "active") return;
137
+ pauseForProviderLimit(ctx, classifyProviderLimit({ status: event.status, headers: event.headers }));
113
138
  });
114
139
 
115
140
  pi.on("context", async (event) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mocito/pi-goal",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Persistent long-running goals for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,12 +53,12 @@
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-ai": "^0.75.4",
57
- "@earendil-works/pi-coding-agent": "^0.75.4",
58
- "@earendil-works/pi-tui": "^0.75.4",
56
+ "@earendil-works/pi-ai": "^0.78.0",
57
+ "@earendil-works/pi-coding-agent": "^0.78.0",
58
+ "@earendil-works/pi-tui": "^0.78.0",
59
59
  "@types/node": "^25.6.2",
60
- "typebox": "^1.1.33",
61
- "tsx": "^4.21.0",
60
+ "typebox": "^1.1.39",
61
+ "tsx": "^4.22.4",
62
62
  "typescript": "^6.0.3"
63
63
  },
64
64
  "publishConfig": {
package/src/accounting.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { BranchEntry, GoalMutation, GoalState } from "./types.js";
2
2
  import { GOAL_SCHEMA_VERSION } from "./types.js";
3
- import { nowIso, realizedTimeUsed } from "./utils.js";
3
+ import { nowIso } from "./utils.js";
4
4
 
5
5
  export interface UsageAccountingResult {
6
6
  mutation?: GoalMutation;
@@ -34,14 +34,13 @@ export function accountUsageFromBranch(goal: GoalState, branchEntries: BranchEnt
34
34
  addedEntryIds.push(entry.id);
35
35
  }
36
36
  if (addedTokens === 0) return { goal, addedTokens: 0, addedEntryIds };
37
- const timeUsedSeconds = realizedTimeUsed(goal, endMs);
37
+ void endMs;
38
38
  const mutation: GoalMutation = {
39
39
  schemaVersion: GOAL_SCHEMA_VERSION,
40
40
  kind: "account",
41
41
  goalId: goal.goalId,
42
42
  tokens: addedTokens,
43
43
  entryIds: addedEntryIds,
44
- timeUsedSeconds,
45
44
  at: nowIso(),
46
45
  };
47
46
  return {
@@ -51,7 +50,6 @@ export function accountUsageFromBranch(goal: GoalState, branchEntries: BranchEnt
51
50
  goal: {
52
51
  ...goal,
53
52
  tokensUsed: goal.tokensUsed + addedTokens,
54
- timeUsedSeconds,
55
53
  accountedUsage: {
56
54
  tokens: goal.tokensUsed + addedTokens,
57
55
  entryIds: [...goal.accountedUsage.entryIds, ...addedEntryIds],
package/src/index.ts CHANGED
@@ -10,3 +10,4 @@ export * from "./tools.js";
10
10
  export * from "./ui.js";
11
11
  export * from "./rendering.js";
12
12
  export * from "./install-telemetry.js";
13
+ export * from "./provider-limits.js";
@@ -0,0 +1,125 @@
1
+ export type ProviderLimitKind = "none" | "rate_limit" | "usage_limit" | "quota_exceeded" | "billing_required" | "auth_required" | "provider_error";
2
+
3
+ export interface ProviderLimitInput {
4
+ status?: number;
5
+ headers?: Record<string, string | string[] | undefined>;
6
+ errorMessage?: string;
7
+ provider?: string;
8
+ model?: string;
9
+ }
10
+
11
+ export interface ProviderLimitClassification {
12
+ kind: ProviderLimitKind;
13
+ pause: boolean;
14
+ reason?: string;
15
+ retryAfterSeconds?: number;
16
+ resetHint?: string;
17
+ }
18
+
19
+ const USAGE_LIMIT_PATTERNS = [
20
+ /GoUsageLimitError/i,
21
+ /\busage limit (?:reached|exceeded)/i,
22
+ /\b\d+\s*-?\s*hour usage limit/i,
23
+ /\bdaily limit\b/i,
24
+ /\bmonthly limit\b/i,
25
+ /\bweekly limit\b/i,
26
+ /\btoken plan\b/i,
27
+ ];
28
+
29
+ const QUOTA_PATTERNS = [
30
+ /insufficient[_\s-]?quota/i,
31
+ /quota (?:exceeded|reached)/i,
32
+ /exceeded (?:your )?quota/i,
33
+ ];
34
+
35
+ const BILLING_PATTERNS = [
36
+ /billing/i,
37
+ /available balance/i,
38
+ /enable usage from your available balance/i,
39
+ /payment required/i,
40
+ /credits? exhausted/i,
41
+ ];
42
+
43
+ const AUTH_PATTERNS = [
44
+ /invalid api key/i,
45
+ /unauthorized/i,
46
+ /forbidden/i,
47
+ /authentication/i,
48
+ ];
49
+
50
+ export function classifyProviderLimit(input: ProviderLimitInput): ProviderLimitClassification {
51
+ const status = input.status;
52
+ const text = extractSearchableText(input);
53
+ const retryAfterSeconds = parseRetryAfter(input.headers);
54
+ const resetHint = extractResetHint(text);
55
+
56
+ if (matchesAny(text, USAGE_LIMIT_PATTERNS)) return { kind: "usage_limit", pause: true, reason: "provider usage limit reached", retryAfterSeconds, resetHint };
57
+ if (matchesAny(text, QUOTA_PATTERNS)) return { kind: "quota_exceeded", pause: true, reason: "provider quota exceeded", retryAfterSeconds, resetHint };
58
+ if (matchesAny(text, BILLING_PATTERNS)) return { kind: "billing_required", pause: true, reason: "provider billing or balance limit", retryAfterSeconds, resetHint };
59
+ if ((status === 401 || status === 403) && matchesAny(text, AUTH_PATTERNS)) return { kind: "auth_required", pause: true, reason: "provider authentication failed", retryAfterSeconds, resetHint };
60
+ if (status === 402) return { kind: "billing_required", pause: true, reason: "provider payment required", retryAfterSeconds, resetHint };
61
+ if (status === 429) return { kind: "rate_limit", pause: true, reason: "provider returned HTTP 429", retryAfterSeconds, resetHint };
62
+ if (status && status >= 500) return { kind: "provider_error", pause: false, reason: `provider returned HTTP ${status}`, retryAfterSeconds, resetHint };
63
+ if (/\b429\b/.test(text) && /rate limit|too many requests|limit/i.test(text)) return { kind: "rate_limit", pause: true, reason: "provider returned a rate limit error", retryAfterSeconds, resetHint };
64
+ return { kind: "none", pause: false, retryAfterSeconds, resetHint };
65
+ }
66
+
67
+ export function classifyAssistantError(message: any): ProviderLimitClassification {
68
+ if (!message || message.role !== "assistant") return { kind: "none", pause: false };
69
+ const raw = typeof message.errorMessage === "string" ? message.errorMessage : "";
70
+ if (!raw && message.stopReason !== "error") return { kind: "none", pause: false };
71
+ return classifyProviderLimit({
72
+ status: extractStatus(raw),
73
+ errorMessage: raw || String(message.stopReason ?? ""),
74
+ provider: message.provider,
75
+ model: message.model,
76
+ });
77
+ }
78
+
79
+ function extractSearchableText(input: ProviderLimitInput): string {
80
+ const parts = [input.errorMessage ?? "", input.provider ?? "", input.model ?? ""];
81
+ if (input.headers) parts.push(JSON.stringify(input.headers));
82
+ try {
83
+ const match = (input.errorMessage ?? "").match(/\{[\s\S]*\}/);
84
+ if (match) parts.push(JSON.stringify(JSON.parse(match[0])));
85
+ } catch {
86
+ // Keep the raw error text if provider payload is not valid JSON.
87
+ }
88
+ return parts.join("\n");
89
+ }
90
+
91
+ function matchesAny(text: string, patterns: RegExp[]): boolean {
92
+ return patterns.some((pattern) => pattern.test(text));
93
+ }
94
+
95
+ function extractStatus(errorMessage: string): number | undefined {
96
+ const match = errorMessage.match(/^\s*(\d{3})\b/);
97
+ if (!match) return undefined;
98
+ const status = Number(match[1]);
99
+ return Number.isFinite(status) ? status : undefined;
100
+ }
101
+
102
+ function parseRetryAfter(headers: ProviderLimitInput["headers"]): number | undefined {
103
+ const value = headerValue(headers, "retry-after");
104
+ if (!value) return undefined;
105
+ const seconds = Number(value);
106
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.floor(seconds);
107
+ const date = Date.parse(value);
108
+ if (!Number.isFinite(date)) return undefined;
109
+ return Math.max(0, Math.ceil((date - Date.now()) / 1000));
110
+ }
111
+
112
+ function headerValue(headers: ProviderLimitInput["headers"], name: string): string | undefined {
113
+ if (!headers) return undefined;
114
+ const found = Object.entries(headers).find(([key]) => key.toLowerCase() === name);
115
+ const value = found?.[1];
116
+ return Array.isArray(value) ? value[0] : value;
117
+ }
118
+
119
+ function extractResetHint(text: string): string | undefined {
120
+ const resets = text.match(/resets?\s+in\s+([^".\n]+)/i);
121
+ if (resets) return `resets in ${resets[1].trim()}`;
122
+ const retry = text.match(/try again\s+in\s+([^".\n]+)/i);
123
+ if (retry) return `try again in ${retry[1].trim()}`;
124
+ return undefined;
125
+ }
package/src/state.ts CHANGED
@@ -63,7 +63,7 @@ export function applyGoalMutation(current: GoalState | null, mutation: GoalMutat
63
63
  case "account": {
64
64
  const ids = [...new Set([...current.accountedUsage.entryIds, ...mutation.entryIds])];
65
65
  const tokens = current.tokensUsed + mutation.tokens;
66
- return { ...current, tokensUsed: tokens, timeUsedSeconds: mutation.timeUsedSeconds ?? current.timeUsedSeconds, accountedUsage: { tokens, entryIds: ids }, updatedAt: mutation.at };
66
+ return { ...current, tokensUsed: tokens, accountedUsage: { tokens, entryIds: ids }, updatedAt: mutation.at };
67
67
  }
68
68
  }
69
69
  }