@phren/agent 0.1.1 → 0.1.3

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.
@@ -93,11 +93,11 @@ function formatToolEnd(toolName, input, output, isError, durationMs) {
93
93
  const icon = isError ? s.red("x") : s.green("ok");
94
94
  const preview = JSON.stringify(input).slice(0, 50);
95
95
  const header = s.dim(` ${toolName}(${preview})`) + ` ${icon} ${s.dim(dur)}`;
96
- const outputLines = output.split("\n").slice(0, 4);
96
+ const allLines = output.split("\n");
97
97
  const w = cols();
98
- const body = outputLines.map((l) => s.dim(` | ${l.slice(0, w - 6)}`)).join("\n");
99
- const more = output.split("\n").length > 4 ? s.dim(` | ... (${output.split("\n").length} lines)`) : "";
100
- return `${header}\n${body}${more ? "\n" + more : ""}`;
98
+ const body = allLines.slice(0, 4).map((l) => s.dim(` | ${l.slice(0, w - 6)}`)).join("\n");
99
+ const more = allLines.length > 4 ? `\n${s.dim(` | ... (${allLines.length} lines)`)}` : "";
100
+ return `${header}\n${body}${more}`;
101
101
  }
102
102
  // ── Main TUI ─────────────────────────────────────────────────────────────────
103
103
  export async function startMultiTui(spawner, config) {
@@ -603,8 +603,6 @@ export async function startMultiTui(spawner, config) {
603
603
  });
604
604
  // Handle terminal resize
605
605
  process.stdout.on("resize", () => render());
606
- // Initial render
607
- render();
608
606
  // Register panes for any agents that already exist
609
607
  for (const agent of spawner.listAgents()) {
610
608
  getOrCreatePane(agent.id);
@@ -58,7 +58,3 @@ export function addAllow(toolName, input, scope) {
58
58
  export function clearAllowlist() {
59
59
  sessionAllowlist.length = 0;
60
60
  }
61
- /** Get a snapshot of the current allowlist (for display). */
62
- export function getAllowlist() {
63
- return sessionAllowlist;
64
- }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Privacy safeguards — scrub sensitive data from tool outputs, findings, and LLM context.
3
+ *
4
+ * Prevents accidental leakage of:
5
+ * - API keys and tokens in tool output (e.g., from reading .env files)
6
+ * - Passwords and connection strings
7
+ * - PII patterns (emails, IPs shown in logs)
8
+ * - Private keys and certificates
9
+ *
10
+ * Applied at three layers:
11
+ * 1. Tool output → before sending to LLM (scrubToolOutput)
12
+ * 2. Findings → before saving to phren (scrubFinding)
13
+ * 3. Session summaries → before persisting (scrubSummary)
14
+ */
15
+ // ── Secret patterns ──────────────────────────────────────────────────────
16
+ /** Patterns that match common API key/token formats. */
17
+ const SECRET_PATTERNS = [
18
+ // Generic API keys (long hex/base64 strings prefixed by common env var names)
19
+ { pattern: /(?:api[_-]?key|api[_-]?secret|api[_-]?token)\s*[:=]\s*["']?([A-Za-z0-9_\-/.+=]{20,})["']?/gi, label: "API_KEY" },
20
+ // AWS keys
21
+ { pattern: /AKIA[0-9A-Z]{16}/g, label: "AWS_ACCESS_KEY" },
22
+ { pattern: /(?:aws[_-]?secret[_-]?access[_-]?key)\s*[:=]\s*["']?([A-Za-z0-9/+=]{30,})["']?/gi, label: "AWS_SECRET" },
23
+ // Bearer tokens
24
+ { pattern: /Bearer\s+[A-Za-z0-9_\-/.+=]{20,}/g, label: "BEARER_TOKEN" },
25
+ // GitHub tokens
26
+ { pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/g, label: "GITHUB_TOKEN" },
27
+ // Anthropic keys
28
+ { pattern: /sk-ant-[A-Za-z0-9_\-]{20,}/g, label: "ANTHROPIC_KEY" },
29
+ // OpenAI keys
30
+ { pattern: /sk-[A-Za-z0-9]{20,}/g, label: "OPENAI_KEY" },
31
+ // Generic password assignments
32
+ { pattern: /(?:password|passwd|pwd)\s*[:=]\s*["']?([^\s"']{8,})["']?/gi, label: "PASSWORD" },
33
+ // Connection strings with passwords
34
+ { pattern: /:\/\/[^:]+:([^@\s]{8,})@/g, label: "CONNECTION_PASSWORD" },
35
+ // Private key blocks
36
+ { pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----/g, label: "PRIVATE_KEY" },
37
+ // JWT tokens
38
+ { pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, label: "JWT" },
39
+ // Slack tokens
40
+ { pattern: /xox[bpras]-[0-9]{10,}-[A-Za-z0-9-]+/g, label: "SLACK_TOKEN" },
41
+ // Env variable assignments with secret-ish names
42
+ { pattern: /(?:SECRET|TOKEN|PASSWORD|PRIVATE_KEY|AUTH|CREDENTIAL)[A-Z_]*\s*=\s*["']?([^\s"']{8,})["']?/gi, label: "SECRET_VAR" },
43
+ ];
44
+ /** Patterns for PII that shouldn't be stored in findings. */
45
+ const PII_PATTERNS = [
46
+ // Email addresses (only redact in contexts where they're likely PII, not code)
47
+ { pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, label: "EMAIL" },
48
+ // IP addresses (v4) — only in log-like contexts
49
+ { pattern: /\b(?:25[0-5]|2[0-4]\d|[01]?\d\d?)(?:\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)){3}\b/g, label: "IP_ADDRESS" },
50
+ ];
51
+ // ── Scrubbing functions ──────────────────────────────────────────────────
52
+ /**
53
+ * Scrub sensitive data from tool output before it's sent to the LLM.
54
+ * This is the primary privacy gate — catches secrets in file reads, command output, etc.
55
+ */
56
+ export function scrubToolOutput(toolName, output) {
57
+ // Don't scrub short outputs (unlikely to contain full secrets)
58
+ if (output.length < 20)
59
+ return output;
60
+ let scrubbed = output;
61
+ for (const { pattern, label } of SECRET_PATTERNS) {
62
+ // Reset regex state for global patterns
63
+ pattern.lastIndex = 0;
64
+ scrubbed = scrubbed.replace(pattern, `[REDACTED:${label}]`);
65
+ }
66
+ return scrubbed;
67
+ }
68
+ /**
69
+ * Check if a string contains likely secrets. Returns true if secrets detected.
70
+ * Use this as a gate before saving to persistent storage.
71
+ */
72
+ export function containsSecrets(text) {
73
+ for (const { pattern } of SECRET_PATTERNS) {
74
+ pattern.lastIndex = 0;
75
+ if (pattern.test(text))
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ /**
81
+ * Scrub sensitive data from a finding before saving to phren.
82
+ * More aggressive than tool output scrubbing — also catches PII.
83
+ */
84
+ export function scrubFinding(finding) {
85
+ let scrubbed = finding;
86
+ // Secret patterns
87
+ for (const { pattern, label } of SECRET_PATTERNS) {
88
+ pattern.lastIndex = 0;
89
+ scrubbed = scrubbed.replace(pattern, `[REDACTED:${label}]`);
90
+ }
91
+ // PII patterns (only in findings, not tool outputs where they may be needed)
92
+ for (const { pattern, label } of PII_PATTERNS) {
93
+ pattern.lastIndex = 0;
94
+ scrubbed = scrubbed.replace(pattern, `[REDACTED:${label}]`);
95
+ }
96
+ return scrubbed;
97
+ }
98
+ /**
99
+ * Scrub a session summary before persisting.
100
+ */
101
+ export function scrubSummary(summary) {
102
+ return scrubFinding(summary);
103
+ }
104
+ /**
105
+ * Check if tool output looks like it came from reading a sensitive file
106
+ * (e.g., .env, credentials). Returns true if the content appears to be
107
+ * mostly key-value secrets.
108
+ */
109
+ export function looksLikeSecretsFile(output) {
110
+ const lines = output.split("\n").filter(l => l.trim() && !l.startsWith("#"));
111
+ if (lines.length < 2)
112
+ return false;
113
+ let secretLines = 0;
114
+ for (const line of lines) {
115
+ // Count lines that look like KEY=secret_value
116
+ if (/^[A-Z_]{2,}=\S+/.test(line)) {
117
+ for (const { pattern } of SECRET_PATTERNS) {
118
+ pattern.lastIndex = 0;
119
+ if (pattern.test(line)) {
120
+ secretLines++;
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ }
126
+ // If >50% of non-comment lines are secrets, it's probably a secrets file
127
+ return secretLines / lines.length > 0.5;
128
+ }
129
+ /**
130
+ * Validate that a finding doesn't contain obvious secrets before saving.
131
+ * Returns an error message if the finding should be rejected, null if OK.
132
+ */
133
+ export function validateFindingSafety(finding) {
134
+ if (containsSecrets(finding)) {
135
+ return "Finding contains detected secrets (API keys, tokens, passwords). Secrets should never be stored in findings. The sensitive values have been redacted.";
136
+ }
137
+ return null;
138
+ }
139
+ /**
140
+ * Patterns that indicate prompt injection — text trying to override AI instructions.
141
+ * Each entry: [regex, flag label].
142
+ */
143
+ const PROMPT_INJECTION_PATTERNS = [
144
+ // Direct instruction override attempts
145
+ [/\b(?:ignore|disregard|forget|override)\s+(?:all\s+)?(?:previous|prior|above|earlier|your|the|safety|system)\b/i, "prompt_injection:instruction_override"],
146
+ [/\byou\s+(?:must|should|shall|will|are\s+(?:now|required\s+to))\b/i, "prompt_injection:directive"],
147
+ [/\byour\s+new\s+(?:instructions?|role|purpose|directive)\b/i, "prompt_injection:role_reassignment"],
148
+ [/\bas\s+an?\s+(?:AI|language\s+model|assistant|LLM)\b/i, "prompt_injection:identity_framing"],
149
+ [/\bforget\s+everything\b/i, "prompt_injection:memory_wipe"],
150
+ // System prompt markers (ChatML, Llama, etc.)
151
+ [/\[INST\]|\[\/INST\]/i, "prompt_injection:chatml_inst"],
152
+ [/<<SYS>>|<<\/SYS>>/i, "prompt_injection:llama_sys"],
153
+ [/<\|im_start\|>|<\|im_end\|>/i, "prompt_injection:chatml_marker"],
154
+ [/^system\s*:/im, "prompt_injection:system_prefix"],
155
+ [/SYSTEM\s*:\s*you\s+are/i, "prompt_injection:system_role"],
156
+ // Jailbreak-style keywords
157
+ [/\b(?:DAN|do\s+anything\s+now|jailbreak)\b/i, "prompt_injection:jailbreak_keyword"],
158
+ ];
159
+ /**
160
+ * Patterns for dangerous executable instructions embedded in findings.
161
+ */
162
+ const DANGEROUS_COMMAND_PATTERNS = [
163
+ // Pipe-to-shell patterns
164
+ [/\bcurl\s+[^\s|]*\s*\|\s*(?:sh|bash|zsh|eval)\b/i, "dangerous_command:curl_pipe_shell"],
165
+ [/\bwget\s+[^\s|]*\s*\|\s*(?:sh|bash|zsh|eval)\b/i, "dangerous_command:wget_pipe_shell"],
166
+ // Dangerous eval/exec
167
+ [/\beval\s*\(\s*['"`].*['"`]\s*\)/i, "dangerous_command:eval_literal"],
168
+ [/\bexec\s*\(\s*['"`].*['"`]\s*\)/i, "dangerous_command:exec_literal"],
169
+ // Destructive commands
170
+ [/\brm\s+-rf\s+[/~]/i, "dangerous_command:rm_rf_root"],
171
+ [/\bdd\s+.*\bof=\/dev\//i, "dangerous_command:dd_device"],
172
+ [/\bmkfs\b/i, "dangerous_command:mkfs"],
173
+ // Instructions to always run dangerous commands
174
+ [/\b(?:always|first)\s+run\s+[`"'].*(?:curl|wget|rm\s+-rf|chmod\s+777|sudo)/i, "dangerous_command:instruction_to_run"],
175
+ ];
176
+ /**
177
+ * Patterns for authority/permission escalation claims.
178
+ */
179
+ const AUTHORITY_ESCALATION_PATTERNS = [
180
+ [/\b(?:user|admin|owner)\s+has\s+(?:authorized|approved|granted|confirmed)\b/i, "authority_escalation:false_authorization"],
181
+ [/\bfull\s+(?:access|permission|control)\s+(?:granted|given|enabled)\b/i, "authority_escalation:false_access"],
182
+ [/\bskip\s+(?:validation|verification|safety|security|auth)\b/i, "authority_escalation:skip_safety"],
183
+ [/\bno\s+need\s+to\s+(?:ask|confirm|verify|validate|check)\b/i, "authority_escalation:skip_confirmation"],
184
+ [/\bauto[-\s]?(?:approve|accept|confirm)\s+(?:all|every)\b/i, "authority_escalation:auto_approve"],
185
+ [/\bdisable\s+(?:safety|security|protection|guard|check|filter)\b/i, "authority_escalation:disable_safety"],
186
+ ];
187
+ /**
188
+ * Patterns for self-replication — findings that instruct saving more findings.
189
+ */
190
+ const SELF_REPLICATION_PATTERNS = [
191
+ [/\b(?:save|add|create|write|store|append)\s+(?:this\s+)?(?:finding|memory|memories|findings)\b/i, "self_replication:save_finding"],
192
+ [/\badd_finding\b/i, "self_replication:tool_invocation"],
193
+ [/\bremember\s+to\s+always\b/i, "self_replication:persistent_instruction"],
194
+ [/\b(?:when|if)\s+you\s+see\s+this\b/i, "self_replication:conditional_trigger"],
195
+ [/\bspread\s+(?:this|the)\s+(?:message|finding|memory)\b/i, "self_replication:spread_instruction"],
196
+ ];
197
+ /**
198
+ * Check a finding for integrity issues — prompt injection, dangerous commands,
199
+ * authority escalation, and self-replication attempts.
200
+ *
201
+ * Returns a structured result with risk level and triggered flags.
202
+ * Risk levels:
203
+ * - "none": no issues detected
204
+ * - "low": one minor flag, likely benign but noted
205
+ * - "medium": multiple flags or a single concerning pattern
206
+ * - "high": strong prompt injection or dangerous command pattern
207
+ */
208
+ export function checkFindingIntegrity(finding) {
209
+ const flags = [];
210
+ // Check all pattern categories
211
+ for (const [pattern, flag] of PROMPT_INJECTION_PATTERNS) {
212
+ pattern.lastIndex = 0;
213
+ if (pattern.test(finding))
214
+ flags.push(flag);
215
+ }
216
+ for (const [pattern, flag] of DANGEROUS_COMMAND_PATTERNS) {
217
+ pattern.lastIndex = 0;
218
+ if (pattern.test(finding))
219
+ flags.push(flag);
220
+ }
221
+ for (const [pattern, flag] of AUTHORITY_ESCALATION_PATTERNS) {
222
+ pattern.lastIndex = 0;
223
+ if (pattern.test(finding))
224
+ flags.push(flag);
225
+ }
226
+ for (const [pattern, flag] of SELF_REPLICATION_PATTERNS) {
227
+ pattern.lastIndex = 0;
228
+ if (pattern.test(finding))
229
+ flags.push(flag);
230
+ }
231
+ if (flags.length === 0) {
232
+ return { safe: true, risk: "none", flags: [] };
233
+ }
234
+ // Determine risk level based on count and severity
235
+ const hasHighSeverity = flags.some(f => f.startsWith("prompt_injection:") || f.startsWith("dangerous_command:") || f.startsWith("authority_escalation:disable_safety"));
236
+ const hasMediumSeverity = flags.some(f => f.startsWith("authority_escalation:") || f.startsWith("self_replication:"));
237
+ let risk;
238
+ if (hasHighSeverity || flags.length >= 3) {
239
+ risk = "high";
240
+ }
241
+ else if (hasMediumSeverity || flags.length >= 2) {
242
+ risk = "medium";
243
+ }
244
+ else {
245
+ risk = "low";
246
+ }
247
+ return { safe: risk !== "high", risk, flags };
248
+ }
@@ -11,6 +11,14 @@ const DANGEROUS_PATTERNS = [
11
11
  { pattern: /\bnohup\b/i, reason: "Detached process may outlive session", severity: "block" },
12
12
  { pattern: /\bdisown\b/i, reason: "Detached process may outlive session", severity: "block" },
13
13
  { pattern: /\bsetsid\b/i, reason: "Detached process may outlive session", severity: "block" },
14
+ // Block: Windows-specific destructive commands
15
+ { pattern: /\bformat\s+[a-z]:/i, reason: "Disk format command", severity: "block" },
16
+ { pattern: /\bdel\s+\/[sq]/i, reason: "Recursive or quiet delete", severity: "block" },
17
+ { pattern: /\brd\s+\/s/i, reason: "Recursive directory removal", severity: "block" },
18
+ { pattern: /\brmdir\s+\/s/i, reason: "Recursive directory removal", severity: "block" },
19
+ { pattern: /\breg\s+delete\b/i, reason: "Registry deletion", severity: "block" },
20
+ { pattern: /\bpowershell\b.*\b-enc\b/i, reason: "Encoded PowerShell command (obfuscation)", severity: "block" },
21
+ { pattern: /\bcmd\b.*\/c.*\bdel\s+\/[sq]/i, reason: "Recursive or quiet delete via cmd", severity: "block" },
14
22
  // Warn: potentially dangerous
15
23
  { pattern: /\beval\b/i, reason: "Dynamic code execution via eval", severity: "warn" },
16
24
  { pattern: /\$\(.*\)/, reason: "Command substitution", severity: "warn" },
@@ -4,28 +4,15 @@ export class AnthropicProvider {
4
4
  maxOutputTokens;
5
5
  apiKey;
6
6
  model;
7
- constructor(apiKey, model, maxOutputTokens) {
7
+ cacheEnabled;
8
+ constructor(apiKey, model, maxOutputTokens, cacheEnabled = true) {
8
9
  this.apiKey = apiKey;
9
10
  this.model = model ?? "claude-sonnet-4-20250514";
10
11
  this.maxOutputTokens = maxOutputTokens ?? 8192;
12
+ this.cacheEnabled = cacheEnabled;
11
13
  }
12
14
  async chat(system, messages, tools) {
13
- const body = {
14
- model: this.model,
15
- system,
16
- messages: messages.map((m) => ({
17
- role: m.role,
18
- content: m.content,
19
- })),
20
- max_tokens: this.maxOutputTokens,
21
- };
22
- if (tools.length > 0) {
23
- body.tools = tools.map((t) => ({
24
- name: t.name,
25
- description: t.description,
26
- input_schema: t.input_schema,
27
- }));
28
- }
15
+ const body = this.buildRequestBody(system, messages, tools);
29
16
  const res = await fetch("https://api.anthropic.com/v1/messages", {
30
17
  method: "POST",
31
18
  headers: {
@@ -45,6 +32,7 @@ export class AnthropicProvider {
45
32
  : data.stop_reason === "max_tokens" ? "max_tokens"
46
33
  : "end_turn";
47
34
  const usage = data.usage;
35
+ logCacheUsage(usage);
48
36
  return {
49
37
  content,
50
38
  stop_reason: stop_reason,
@@ -52,20 +40,8 @@ export class AnthropicProvider {
52
40
  };
53
41
  }
54
42
  async *chatStream(system, messages, tools) {
55
- const body = {
56
- model: this.model,
57
- system,
58
- messages: messages.map((m) => ({ role: m.role, content: m.content })),
59
- max_tokens: this.maxOutputTokens,
60
- stream: true,
61
- };
62
- if (tools.length > 0) {
63
- body.tools = tools.map((t) => ({
64
- name: t.name,
65
- description: t.description,
66
- input_schema: t.input_schema,
67
- }));
68
- }
43
+ const body = this.buildRequestBody(system, messages, tools);
44
+ body.stream = true;
69
45
  const res = await fetch("https://api.anthropic.com/v1/messages", {
70
46
  method: "POST",
71
47
  headers: {
@@ -129,6 +105,7 @@ export class AnthropicProvider {
129
105
  else if (type === "message_start") {
130
106
  const u = data.message?.usage;
131
107
  if (u) {
108
+ logCacheUsage(u);
132
109
  usage = {
133
110
  input_tokens: u.input_tokens ?? 0,
134
111
  output_tokens: u.output_tokens ?? 0,
@@ -138,6 +115,66 @@ export class AnthropicProvider {
138
115
  }
139
116
  yield { type: "done", stop_reason: stopReason, usage };
140
117
  }
118
+ /** Build the request body with optional prompt caching breakpoints. */
119
+ buildRequestBody(system, messages, tools) {
120
+ const cache = { cache_control: { type: "ephemeral" } };
121
+ // System prompt: use content array format with cache_control on the text block
122
+ const systemValue = this.cacheEnabled
123
+ ? [{ type: "text", text: system, ...cache }]
124
+ : system;
125
+ const mappedMessages = messages.map((m) => ({ role: m.role, content: m.content }));
126
+ // Mark the last 2 user messages with cache_control for recent-context caching
127
+ if (this.cacheEnabled) {
128
+ let marked = 0;
129
+ for (let i = mappedMessages.length - 1; i >= 0 && marked < 2; i--) {
130
+ if (mappedMessages[i].role !== "user")
131
+ continue;
132
+ const c = mappedMessages[i].content;
133
+ if (typeof c === "string") {
134
+ mappedMessages[i] = {
135
+ role: "user",
136
+ content: [{ type: "text", text: c, ...cache }],
137
+ };
138
+ }
139
+ else if (Array.isArray(c) && c.length > 0) {
140
+ // Add cache_control to the last block of the content array
141
+ const blocks = [...c];
142
+ blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], ...cache };
143
+ mappedMessages[i] = { role: "user", content: blocks };
144
+ }
145
+ marked++;
146
+ }
147
+ }
148
+ const body = {
149
+ model: this.model,
150
+ system: systemValue,
151
+ messages: mappedMessages,
152
+ max_tokens: this.maxOutputTokens,
153
+ };
154
+ if (tools.length > 0) {
155
+ const mappedTools = tools.map((t) => ({
156
+ name: t.name,
157
+ description: t.description,
158
+ input_schema: t.input_schema,
159
+ }));
160
+ // Cache the last tool definition — Anthropic uses it as the breakpoint for the entire tools block
161
+ if (this.cacheEnabled) {
162
+ mappedTools[mappedTools.length - 1] = { ...mappedTools[mappedTools.length - 1], ...cache };
163
+ }
164
+ body.tools = mappedTools;
165
+ }
166
+ return body;
167
+ }
168
+ }
169
+ /** Log cache hit/creation stats to stderr (visible in verbose mode). */
170
+ function logCacheUsage(usage) {
171
+ if (!usage)
172
+ return;
173
+ const created = usage.cache_creation_input_tokens;
174
+ const read = usage.cache_read_input_tokens;
175
+ if (created || read) {
176
+ process.stderr.write(`[cache] created=${created ?? 0} read=${read ?? 0} input=${usage.input_tokens ?? 0}\n`);
177
+ }
141
178
  }
142
179
  /** Parse SSE stream from a fetch Response. */
143
180
  async function* parseSSE(res) {
@@ -194,73 +194,129 @@ export class CodexProvider {
194
194
  body.tools = toResponsesTools(tools);
195
195
  body.tool_choice = "auto";
196
196
  }
197
- const res = await fetch(CODEX_API, {
198
- method: "POST",
197
+ // Use WebSocket for true token-by-token streaming (matches Codex CLI behavior).
198
+ // The HTTP SSE endpoint batches the entire response before flushing.
199
+ yield* this.chatStreamWs(accessToken, body);
200
+ }
201
+ /** WebSocket streaming — sends request, yields deltas as they arrive. */
202
+ async *chatStreamWs(accessToken, body) {
203
+ const wsUrl = CODEX_API.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
204
+ // Queue for events received from the WebSocket before the consumer pulls them
205
+ const queue = [];
206
+ let resolve = null;
207
+ let done = false;
208
+ const push = (item) => {
209
+ queue.push(item);
210
+ if (resolve) {
211
+ resolve();
212
+ resolve = null;
213
+ }
214
+ };
215
+ // Node.js (undici) WebSocket accepts headers in the second argument object,
216
+ // but the DOM typings only allow string | string[]. Cast to bypass.
217
+ const ws = new WebSocket(wsUrl, {
199
218
  headers: {
200
- "Content-Type": "application/json",
201
219
  Authorization: `Bearer ${accessToken}`,
202
220
  },
203
- body: JSON.stringify(body),
204
221
  });
205
- if (!res.ok) {
206
- const text = await res.text();
207
- throw new Error(`Codex API error ${res.status}: ${text}`);
208
- }
209
- // Parse SSE stream
210
- if (!res.body)
211
- throw new Error("Provider returned empty response body");
212
- const reader = res.body.getReader();
213
- const decoder = new TextDecoder();
214
- let buffer = "";
215
222
  let activeToolCallId = "";
216
- while (true) {
217
- const { done, value } = await reader.read();
218
- if (done)
219
- break;
220
- buffer += decoder.decode(value, { stream: true });
221
- const lines = buffer.split("\n");
222
- buffer = lines.pop();
223
- for (const line of lines) {
224
- if (!line.startsWith("data: "))
225
- continue;
226
- const data = line.slice(6).trim();
227
- if (data === "[DONE]")
228
- return;
229
- let event;
223
+ ws.addEventListener("open", () => {
224
+ // Wrap the request body in a response.create envelope (Codex WS protocol)
225
+ const wsRequest = { type: "response.create", ...body };
226
+ ws.send(JSON.stringify(wsRequest));
227
+ });
228
+ ws.addEventListener("message", (evt) => {
229
+ const data = typeof evt.data === "string" ? evt.data : String(evt.data);
230
+ let event;
231
+ try {
232
+ event = JSON.parse(data);
233
+ }
234
+ catch {
235
+ return;
236
+ }
237
+ const type = event.type;
238
+ // Handle server-side errors
239
+ if (type === "error") {
240
+ const err = event.error;
241
+ const msg = err?.message ?? "Codex WebSocket error";
242
+ const status = event.status;
243
+ push(new Error(`Codex WS error${status ? ` ${status}` : ""}: ${msg}`));
244
+ done = true;
230
245
  try {
231
- event = JSON.parse(data);
232
- }
233
- catch {
234
- continue;
246
+ ws.close();
235
247
  }
236
- const type = event.type;
237
- if (type === "response.output_text.delta") {
238
- yield { type: "text_delta", text: event.delta };
239
- }
240
- else if (type === "response.output_item.added") {
241
- if (event.item?.type === "function_call") {
242
- const item = event.item;
243
- activeToolCallId = item.call_id;
244
- yield { type: "tool_use_start", id: activeToolCallId, name: item.name };
245
- }
248
+ catch { /* ignore */ }
249
+ return;
250
+ }
251
+ if (type === "response.output_text.delta") {
252
+ const delta = event.delta;
253
+ if (delta)
254
+ push({ type: "text_delta", text: delta });
255
+ }
256
+ else if (type === "response.output_item.added") {
257
+ if (event.item?.type === "function_call") {
258
+ const item = event.item;
259
+ activeToolCallId = item.call_id;
260
+ push({ type: "tool_use_start", id: activeToolCallId, name: item.name });
246
261
  }
247
- else if (type === "response.function_call_arguments.delta") {
248
- yield { type: "tool_use_delta", id: activeToolCallId, json: event.delta };
262
+ }
263
+ else if (type === "response.function_call_arguments.delta") {
264
+ push({ type: "tool_use_delta", id: activeToolCallId, json: event.delta });
265
+ }
266
+ else if (type === "response.function_call_arguments.done") {
267
+ push({ type: "tool_use_end", id: activeToolCallId });
268
+ }
269
+ else if (type === "response.completed") {
270
+ const response = event.response;
271
+ const usage = response?.usage;
272
+ const output = response?.output;
273
+ const hasToolCalls = output?.some((o) => o.type === "function_call");
274
+ push({
275
+ type: "done",
276
+ stop_reason: hasToolCalls ? "tool_use" : "end_turn",
277
+ usage: usage ? { input_tokens: usage.input_tokens ?? 0, output_tokens: usage.output_tokens ?? 0 } : undefined,
278
+ });
279
+ done = true;
280
+ try {
281
+ ws.close();
249
282
  }
250
- else if (type === "response.function_call_arguments.done") {
251
- yield { type: "tool_use_end", id: activeToolCallId };
283
+ catch { /* ignore */ }
284
+ }
285
+ });
286
+ ws.addEventListener("error", () => {
287
+ if (!done) {
288
+ push(new Error("Codex WebSocket connection error"));
289
+ done = true;
290
+ }
291
+ });
292
+ ws.addEventListener("close", () => {
293
+ if (!done) {
294
+ push(new Error("Codex WebSocket closed before response.completed"));
295
+ done = true;
296
+ }
297
+ });
298
+ // Async iteration: drain the queue, wait for new events
299
+ try {
300
+ while (true) {
301
+ while (queue.length > 0) {
302
+ const item = queue.shift();
303
+ if (item instanceof Error)
304
+ throw item;
305
+ yield item;
306
+ if (item.type === "done")
307
+ return;
252
308
  }
253
- else if (type === "response.completed") {
254
- const response = event.response;
255
- const usage = response?.usage;
256
- const output = response?.output;
257
- const hasToolCalls = output?.some((o) => o.type === "function_call");
258
- yield {
259
- type: "done",
260
- stop_reason: hasToolCalls ? "tool_use" : "end_turn",
261
- usage: usage ? { input_tokens: usage.input_tokens ?? 0, output_tokens: usage.output_tokens ?? 0 } : undefined,
262
- };
309
+ if (done)
310
+ return;
311
+ await new Promise((r) => { resolve = r; });
312
+ }
313
+ }
314
+ finally {
315
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
316
+ try {
317
+ ws.close();
263
318
  }
319
+ catch { /* ignore */ }
264
320
  }
265
321
  }
266
322
  }
package/dist/repl.js CHANGED
@@ -82,7 +82,7 @@ export async function startRepl(config) {
82
82
  rl.prompt();
83
83
  continue;
84
84
  }
85
- if (handleCommand(trimmed, { session, contextLimit, undoStack: [] })) {
85
+ if (handleCommand(trimmed, { session, contextLimit, undoStack: [], phrenCtx: config.phrenCtx })) {
86
86
  rl.prompt();
87
87
  continue;
88
88
  }
@@ -118,7 +118,7 @@ export async function startRepl(config) {
118
118
  process.stderr.write(`${YELLOW}Input mode: ${inputMode}${RESET}\n`);
119
119
  }
120
120
  else {
121
- handleCommand(queued, { session, contextLimit, undoStack: [] });
121
+ handleCommand(queued, { session, contextLimit, undoStack: [], phrenCtx: config.phrenCtx });
122
122
  }
123
123
  break;
124
124
  }