pi-harness-runtime 0.3.2-beta.2 → 0.4.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.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Notification Center — RFC-0022
3
+ *
4
+ * Main orchestration class for sending notifications across multiple channels.
5
+ *
6
+ * Security Rules (RFC-0022):
7
+ * - Do not send raw cookies, passwords, provider tokens
8
+ * - Redact sensitive data before sending
9
+ * - Notification failure does not crash runtime
10
+ */
11
+
12
+ import type {
13
+ NotificationEvent,
14
+ NotificationPayload,
15
+ NotificationConfig,
16
+ NotificationChannelConfig,
17
+ NotificationResult,
18
+ NotificationContext,
19
+ } from "./types.js";
20
+ import type { ChannelAdapter } from "./base-adapter.js";
21
+ import { TelegramAdapter } from "./adapters/telegram-adapter.js";
22
+ import { NtfyAdapter } from "./adapters/ntfy-adapter.js";
23
+ import { EmailAdapter } from "./adapters/email-adapter.js";
24
+ import { WebhookAdapter } from "./adapters/webhook-adapter.js";
25
+
26
+ export class NotificationCenter {
27
+ private adapters: Map<string, ChannelAdapter> = new Map();
28
+ private redactPatterns: RegExp[];
29
+
30
+ constructor(config?: NotificationConfig) {
31
+ this.redactPatterns =
32
+ config?.redactPatterns ?? this.getDefaultRedactPatterns();
33
+
34
+ if (config?.channels) {
35
+ for (const channelConfig of config.channels) {
36
+ if (channelConfig.enabled) {
37
+ this.registerAdapter(channelConfig);
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Register a new adapter
45
+ */
46
+ registerAdapter(config: NotificationChannelConfig): boolean {
47
+ try {
48
+ const adapter = this.createAdapter(config);
49
+ if (adapter && adapter.isConfigured()) {
50
+ this.adapters.set(config.id, adapter);
51
+ return true;
52
+ }
53
+ } catch (error) {
54
+ console.error(
55
+ `[NotificationCenter] Failed to register adapter: ${error}`,
56
+ );
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Initialize all registered adapters
63
+ */
64
+ async initialize(): Promise<void> {
65
+ const adapterEntries = Array.from(this.adapters.entries());
66
+ for (const [id, adapter] of adapterEntries) {
67
+ try {
68
+ const ok = await adapter.initialize();
69
+ if (!ok) {
70
+ console.warn(
71
+ `[NotificationCenter] Adapter ${id} initialization failed`,
72
+ );
73
+ }
74
+ } catch (error) {
75
+ console.warn(
76
+ `[NotificationCenter] Adapter ${id} initialization error: ${error}`,
77
+ );
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Send a notification to all configured channels
84
+ */
85
+ async notify(
86
+ event: NotificationEvent,
87
+ context: NotificationContext,
88
+ ): Promise<NotificationResult[]> {
89
+ const payload = this.buildPayload(event, context);
90
+ const results: NotificationResult[] = [];
91
+
92
+ // Send to all adapters in parallel
93
+ const adapterEntries = Array.from(this.adapters.entries());
94
+ const promises = adapterEntries.map(async ([id, adapter]) => {
95
+ try {
96
+ // Redact sensitive data
97
+ const redactedPayload = this.redact(payload);
98
+ const result = await adapter.send(redactedPayload);
99
+ results.push(result);
100
+ } catch (error) {
101
+ // Never crash the runtime due to notification failure
102
+ results.push({
103
+ success: false,
104
+ channel: id,
105
+ error: String(error),
106
+ });
107
+ }
108
+ });
109
+
110
+ await Promise.all(promises);
111
+ return results;
112
+ }
113
+
114
+ /**
115
+ * Send notification to a specific channel
116
+ */
117
+ async notifyChannel(
118
+ channelId: string,
119
+ event: NotificationEvent,
120
+ context: NotificationContext,
121
+ ): Promise<NotificationResult> {
122
+ const adapter = this.adapters.get(channelId);
123
+ if (!adapter) {
124
+ return { success: false, channel: channelId, error: "Adapter not found" };
125
+ }
126
+
127
+ try {
128
+ const payload = this.redact(this.buildPayload(event, context));
129
+ return await adapter.send(payload);
130
+ } catch (error) {
131
+ return { success: false, channel: channelId, error: String(error) };
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Check if any notifications are configured
137
+ */
138
+ hasChannels(): boolean {
139
+ return this.adapters.size > 0;
140
+ }
141
+
142
+ /**
143
+ * List all configured channels
144
+ */
145
+ listChannels(): string[] {
146
+ return Array.from(this.adapters.keys());
147
+ }
148
+
149
+ // ─── Private Methods ────────────────────────────────────────────────
150
+
151
+ private createAdapter(
152
+ config: NotificationChannelConfig,
153
+ ): ChannelAdapter | null {
154
+ switch (config.type) {
155
+ case "telegram":
156
+ return new TelegramAdapter(
157
+ config.config as import("./types.js").TelegramConfig,
158
+ );
159
+ case "ntfy":
160
+ return new NtfyAdapter(
161
+ config.config as import("./types.js").NtfyConfig,
162
+ );
163
+ case "email":
164
+ return new EmailAdapter(
165
+ config.config as import("./types.js").EmailConfig,
166
+ );
167
+ case "webhook":
168
+ return new WebhookAdapter(
169
+ config.config as import("./types.js").WebhookConfig,
170
+ );
171
+ default:
172
+ return null;
173
+ }
174
+ }
175
+
176
+ private buildPayload(
177
+ event: NotificationEvent,
178
+ context: NotificationContext,
179
+ ): NotificationPayload {
180
+ const { title, message } = this.getEventContent(event, context);
181
+
182
+ return {
183
+ event,
184
+ jobId: context.jobId,
185
+ timestamp: new Date().toISOString(),
186
+ title,
187
+ message,
188
+ details: {
189
+ jobId: context.jobId,
190
+ requirement: context.requirement,
191
+ ...(context.taskId && { taskId: context.taskId }),
192
+ ...(context.taskTitle && { taskTitle: context.taskTitle }),
193
+ ...(context.error && { error: context.error }),
194
+ },
195
+ };
196
+ }
197
+
198
+ private getEventContent(
199
+ event: NotificationEvent,
200
+ context: NotificationContext,
201
+ ): { title: string; message: string } {
202
+ const requirement =
203
+ context.requirement.length > 50
204
+ ? context.requirement.slice(0, 50) + "..."
205
+ : context.requirement;
206
+
207
+ const map: Record<NotificationEvent, { title: string; message: string }> = {
208
+ JobStarted: {
209
+ title: "Job Started",
210
+ message: `Harness job started for: "${requirement}"`,
211
+ },
212
+ TaskCompleted: {
213
+ title: "Task Completed",
214
+ message: `Task "${context.taskTitle ?? "Unknown"}" completed successfully`,
215
+ },
216
+ TaskFailed: {
217
+ title: "Task Failed",
218
+ message: `Task "${context.taskTitle ?? "Unknown"}" failed${context.error ? `: ${context.error}` : ""}`,
219
+ },
220
+ QuotaPaused: {
221
+ title: "Quota Paused",
222
+ message: `Job paused due to quota limit. Will auto-resume when quota resets.`,
223
+ },
224
+ ResumeScheduled: {
225
+ title: "Resume Scheduled",
226
+ message: `Job will resume work on: "${requirement}"`,
227
+ },
228
+ ContextCompacted: {
229
+ title: "Context Compacted",
230
+ message: `Session context was compacted to continue work on: "${requirement}"`,
231
+ },
232
+ OutputLimitContinued: {
233
+ title: "Output Limit Continued",
234
+ message: `Response was continued after hitting output token limit`,
235
+ },
236
+ E2EFailed: {
237
+ title: "E2E Test Failed",
238
+ message: `End-to-end tests failed for job: "${requirement}"`,
239
+ },
240
+ HumanReviewNeeded: {
241
+ title: "Human Review Needed",
242
+ message: `Job blocked. Please review and take action.`,
243
+ },
244
+ ReadyForClient: {
245
+ title: "Ready for Review",
246
+ message: `Job completed successfully and ready for your review: "${requirement}"`,
247
+ },
248
+ JobCancelled: {
249
+ title: "Job Cancelled",
250
+ message: `Job was cancelled: "${requirement}"`,
251
+ },
252
+ Error: {
253
+ title: "Runtime Error",
254
+ message: `An error occurred${context.error ? `: ${context.error}` : ""}`,
255
+ },
256
+ };
257
+
258
+ return map[event] ?? { title: event, message: `Event: ${event}` };
259
+ }
260
+
261
+ private redact(payload: NotificationPayload): NotificationPayload {
262
+ const details = payload.details ? { ...payload.details } : {};
263
+
264
+ // Redact sensitive patterns
265
+ for (const pattern of this.redactPatterns) {
266
+ for (const [key, value] of Object.entries(details)) {
267
+ if (typeof value === "string" && pattern.test(value)) {
268
+ details[key] = "[REDACTED]";
269
+ }
270
+ }
271
+ }
272
+
273
+ return { ...payload, details };
274
+ }
275
+
276
+ private getDefaultRedactPatterns(): RegExp[] {
277
+ return [
278
+ /Bearer\s+[\w-]+/gi, // Bearer tokens
279
+ /password["\s:=]+[^\s,}]+/gi, // passwords
280
+ /cookie["\s:=]+[^\s,}]+/gi, // cookies
281
+ /secret["\s:=]+[^\s,}]+/gi, // secrets
282
+ /api[_-]?key["\s:=]+[^\s,}]+/gi, // API keys
283
+ /auth["\s:=]+[^\s,}]+/gi, // auth tokens
284
+ ];
285
+ }
286
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Notification Center Types — RFC-0022
3
+ *
4
+ * Type definitions for notification events, channels, and payloads.
5
+ */
6
+
7
+ export type NotificationEvent =
8
+ | "JobStarted"
9
+ | "TaskCompleted"
10
+ | "TaskFailed"
11
+ | "QuotaPaused"
12
+ | "ResumeScheduled"
13
+ | "ContextCompacted"
14
+ | "OutputLimitContinued"
15
+ | "E2EFailed"
16
+ | "HumanReviewNeeded"
17
+ | "ReadyForClient"
18
+ | "JobCancelled"
19
+ | "Error";
20
+
21
+ export interface NotificationPayload {
22
+ event: NotificationEvent;
23
+ jobId: string;
24
+ timestamp: string;
25
+ title: string;
26
+ message: string;
27
+ details?: Record<string, unknown>;
28
+ // Redacted fields for security
29
+ redacted?: string[];
30
+ }
31
+
32
+ export interface NotificationConfig {
33
+ channels: NotificationChannelConfig[];
34
+ // Global settings
35
+ enabled?: boolean;
36
+ redactPatterns?: RegExp[];
37
+ }
38
+
39
+ export interface NotificationChannelConfig {
40
+ id: string;
41
+ type: NotificationChannelType;
42
+ enabled: boolean;
43
+ config: TelegramConfig | NtfyConfig | EmailConfig | WebhookConfig;
44
+ }
45
+
46
+ export type NotificationChannelType = "telegram" | "ntfy" | "email" | "webhook";
47
+
48
+ export interface TelegramConfig {
49
+ botToken: string;
50
+ chatId: string;
51
+ parseMode?: "MarkdownV2" | "HTML" | "Markdown";
52
+ }
53
+
54
+ export interface NtfyConfig {
55
+ server: string; // e.g., "https://ntfy.sh"
56
+ topic: string;
57
+ authToken?: string;
58
+ }
59
+
60
+ export interface EmailConfig {
61
+ smtpHost: string;
62
+ smtpPort: number;
63
+ smtpUser: string;
64
+ smtpPassword: string;
65
+ from: string;
66
+ to: string[];
67
+ tls?: boolean;
68
+ }
69
+
70
+ export interface WebhookConfig {
71
+ url: string;
72
+ method?: "POST" | "PUT";
73
+ headers?: Record<string, string>;
74
+ authToken?: string;
75
+ }
76
+
77
+ export interface NotificationResult {
78
+ success: boolean;
79
+ channel: string;
80
+ error?: string;
81
+ }
82
+
83
+ export interface NotificationContext {
84
+ jobId: string;
85
+ requirement: string;
86
+ taskId?: string;
87
+ taskTitle?: string;
88
+ error?: string;
89
+ }
@@ -15,7 +15,7 @@ import type {
15
15
  ProviderCapability,
16
16
  ProviderRequest,
17
17
  ProviderResponse,
18
- } from "../../packages/types/src/runtime-types.ts";
18
+ } from "../../packages/types/src/runtime-types.js";
19
19
 
20
20
  export interface AdapterConfig {
21
21
  provider: ProviderConfig;
@@ -212,6 +212,86 @@ export class OpenAIAdapter extends BaseProviderAdapter {
212
212
  }
213
213
  }
214
214
 
215
+ /**
216
+ * Claude adapter — Anthropic's Claude models
217
+ */
218
+ export class ClaudeAdapter extends BaseProviderAdapter {
219
+ readonly id = "anthropic";
220
+ readonly name = "Anthropic Claude";
221
+
222
+ async invoke(request: ProviderRequest): Promise<AdapterResult> {
223
+ // In practice, this would call the Claude API via Anthropic SDK
224
+ // For now, return a mock response
225
+ return {
226
+ response: {
227
+ content: "Mock response",
228
+ usage: { input: 100, output: 200, cost: 0.003 },
229
+ model: request.model,
230
+ finishReason: "stop",
231
+ },
232
+ retryable: false,
233
+ };
234
+ }
235
+
236
+ parseError(error: unknown): {
237
+ quotaExceeded: boolean;
238
+ rateLimited: boolean;
239
+ timeout: boolean;
240
+ serverError: boolean;
241
+ clientError: boolean;
242
+ quotaSignal?: QuotaSignal;
243
+ } {
244
+ const e = error as Record<string, unknown>;
245
+ const msg = String(e.message ?? e.error ?? "").toLowerCase();
246
+ const type = String(e.type ?? "");
247
+ const errorCode = String(e.code ?? "");
248
+
249
+ // Anthropic-specific error handling
250
+ const isRateLimited =
251
+ type.includes("rate_limit_error") ||
252
+ msg.includes("rate limit") ||
253
+ errorCode.includes("429");
254
+
255
+ const isOverloaded =
256
+ type.includes("overloaded_error") || msg.includes("overloaded");
257
+
258
+ return {
259
+ quotaExceeded:
260
+ type.includes("quota_error") ||
261
+ type.includes("insufficient_quota") ||
262
+ msg.includes("quota exceeded"),
263
+ rateLimited: isRateLimited || isOverloaded,
264
+ timeout: type.includes("timeout") || msg.includes("timeout"),
265
+ serverError: type.includes("api_error") || isOverloaded,
266
+ clientError:
267
+ type.includes("invalid_request_error") ||
268
+ type.includes("authentication_error") ||
269
+ type.includes("permission_error"),
270
+ quotaSignal: type.includes("quota_error")
271
+ ? { exhausted: true, resetsAt: undefined }
272
+ : undefined,
273
+ };
274
+ }
275
+
276
+ getDefaultModel(): string {
277
+ return "anthropic/claude-3-5-sonnet-20240620";
278
+ }
279
+
280
+ getMaxTokens(model?: string): number {
281
+ const limits: Record<string, number> = {
282
+ "anthropic/claude-3-5-sonnet-20240620": 200000,
283
+ "anthropic/claude-3-5-haiku-20240620": 200000,
284
+ "anthropic/claude-3-opus-20240229": 200000,
285
+ "anthropic/claude-3-sonnet-20240229": 200000,
286
+ "anthropic/claude-3-haiku-20240307": 200000,
287
+ "anthropic/claude-2.1": 200000,
288
+ "anthropic/claude-2": 100000,
289
+ "anthropic/claude-instant": 100000,
290
+ };
291
+ return limits[model ?? this.getDefaultModel()] ?? 200000;
292
+ }
293
+ }
294
+
215
295
  /**
216
296
  * Adapter registry
217
297
  */
@@ -256,6 +336,27 @@ export class AdapterRegistry {
256
336
  }),
257
337
  );
258
338
 
339
+ registry.register(
340
+ new ClaudeAdapter({
341
+ id: "anthropic",
342
+ name: "Anthropic Claude",
343
+ models: [
344
+ "anthropic/claude-3-5-sonnet-20240620",
345
+ "anthropic/claude-3-5-haiku-20240620",
346
+ "anthropic/claude-3-opus-20240229",
347
+ ],
348
+ capabilities: [
349
+ "code",
350
+ "review",
351
+ "plan",
352
+ "test",
353
+ "refactor",
354
+ "analysis",
355
+ ],
356
+ rateLimits: {},
357
+ }),
358
+ );
359
+
259
360
  return registry;
260
361
  }
261
362
  }
@@ -212,7 +212,9 @@ export type ProviderCapability =
212
212
  | "plan"
213
213
  | "test"
214
214
  | "e2e"
215
- | "refactor";
215
+ | "refactor"
216
+ | "analysis"
217
+ | "debug";
216
218
 
217
219
  export interface RateLimitConfig {
218
220
  requestsPerMinute?: number;
@@ -1,6 +1,10 @@
1
+ ---
2
+ description: Local-first, provider-agnostic AI coding harness runtime for pi.dev with quota management and multi-model coordination
3
+ ---
4
+
1
5
  # Harness Runtime — pi Extension
2
6
 
3
- **Status:** v0.2.0 | **RFCs:** 18 defined | **Implementation:** Phase 1-6
7
+ **Status:** v0.3.0 | **RFCs:** 18 defined | **Implementation:** Phase 1-6
4
8
 
5
9
  ## Overview
6
10