@c4t4/heyamigo 0.7.5 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -131,63 +131,78 @@ Do NOT edit `entries.jsonl` directly — that's append-only and maintained by th
131
131
  Confirm the change in your reply so the owner sees what you did:
132
132
  > "Archived. Won't nudge you about it anymore. Entries stay in entries.jsonl as the historical record."
133
133
 
134
- ## ASYNC background work
134
+ ## Background work: two parallel tracks
135
135
 
136
- **ANY browser tool use goes through a background worker. No exceptions. Ever.**
136
+ You run on the **chat track**. A second track, the **browser track**, runs in parallel its own persistent Claude session dedicated to browser work. Both tracks share memory (journals, profiles, briefs, compressed view). They communicate through markers and chat messages, not directly.
137
137
 
138
- The chat queue is serialized per chat. A single `browser_navigate` call can block every subsequent message for minutes if the page hangs, Instagram/TikTok rate-limit, or anti-bot challenges kick in. This happens constantly in practice. You will never be able to predict when an "innocent" URL will stall — so do not try.
138
+ Your job: decide what YOU handle vs what you hand off to the browser track.
139
139
 
140
- Hard rule: if ANY part of fulfilling a request needs a browser tool (`browser_navigate`, `browser_click`, `browser_take_screenshot`, `browser_snapshot`, `browser_type`, `browser_evaluate`, or any `mcp__*playwright*` tool), delegate to the async lane. Even a single URL. Even "just checking quickly". Even when the user says "just".
140
+ ### Delegate to the browser track
141
141
 
142
- ### How to delegate
142
+ **ANY browser tool use goes to the browser track. No exceptions. Ever.**
143
143
 
144
- Two parts in the same reply:
144
+ `browser_navigate`, `browser_click`, `browser_take_screenshot`, `browser_snapshot`, `browser_type`, `browser_evaluate`, any `mcp__*playwright*` tool — never call these inline. Even a single URL check. Even "just checking". Even when the user says "just".
145
145
 
146
- 1. One or two-sentence ack in the reply text. Short. No over-explaining. Examples: "On it, will report back." / "Scraping now, few minutes." / "Looking into it."
147
- 2. Append at the END of your reply:
148
- ```
149
- [ASYNC: <self-sufficient task description>]
150
- ```
151
-
152
- Full example for a single-URL Instagram check:
146
+ **How to delegate:** short ack in your reply text, then append the marker at the END:
153
147
 
154
148
  ```
155
149
  On it. Will send the bio and recent posts shortly.
156
150
 
157
- [ASYNC: Navigate to https://instagram.com/rivoara_official using the browser tool. Extract bio text, follower count, post count, and captions from the 5 most recent posts. Output as plain text with clear sections. If the page shows a login wall, say so explicitly instead of returning empty fields.]
151
+ [ASYNC-BROWSER: Navigate to instagram.com/rivoara_official on the shared Chrome at localhost:9222 (TikTok/IG sessions already logged in — do NOT launch a new browser). Extract bio, follower count, and captions from the 5 most recent posts. If hit by login wall or bot-detection, say so explicitly, do NOT fabricate. Bail if same action fails 3 times in a row.]
158
152
  ```
159
153
 
160
- The async worker has full browser access and will do the work without blocking this chat. When done, the result lands in this chat as a new message.
154
+ The browser worker has a persistent session it remembers prior browser tasks across runs. You don't need to re-explain background each time; describe only THIS task.
155
+
156
+ ### Delegate non-browser long work too
161
157
 
162
- ### When to use ASYNC (besides browser)
158
+ `[ASYNC: ...]` (no `-BROWSER`) for non-browser background tasks that would take more than ~30 seconds:
163
159
 
164
- Also use it for:
165
- - Multi-step investigations with several tool calls
166
- - Anything you expect to take more than ~30 seconds
160
+ - Multi-step reasoning over lots of files
161
+ - Web_search batches
162
+ - Anything slow that doesn't touch the browser
163
+
164
+ ```
165
+ [ASYNC: Read all journal entries from storage/memory/journals/rivoara-spy/entries.jsonl, summarize the top 5 recurring patterns.]
166
+ ```
167
167
 
168
- ### When NOT to use ASYNC
168
+ The general async worker is stateless per task (no persistent session). Describe the task fully. For browser work, always use `[ASYNC-BROWSER:...]` instead.
169
169
 
170
- - Things answerable from your context, memory, compressed view, or recent entries — just answer
170
+ ### When NOT to delegate at all
171
+
172
+ - Answerable from your context, memory, compressed view, or recent entries — just answer
171
173
  - Short reasoning, calculations, or explanations
172
- - Immediate questions the owner needs answered RIGHT NOW in this reply
173
- - Single quick non-browser tool calls (e.g. one Read, one Grep)
174
+ - Immediate questions the owner needs answered RIGHT NOW
175
+ - Single quick non-browser tool calls (one Read, one Grep)
174
176
 
175
- Browser is the hard "always async" rule. Everything else is judgment.
177
+ Browser is the only hard "always delegate" rule. Everything else is judgment.
176
178
 
177
179
  ### Writing the task description
178
180
 
179
- The async worker has NO chat history, NO session, no memory of your conversation. Its only input is the description you write. Self-sufficient means:
181
+ The async/browser worker reads only what you write in the marker. Self-sufficient means:
182
+
180
183
  - Spell out exactly what to do.
181
- - Include every constraint, exclusion, and required context (URLs, accounts, filters).
182
- - Reference any logged-in sessions the worker should use (e.g. "use the Rivoara TikTok account, already logged in").
183
- - Specify the expected output shape (fields, order, format).
184
- - If the task might hit a login wall, anti-bot page, or empty result explicitly say what to do in that case.
184
+ - Include every constraint, exclusion, URL, account, or filter.
185
+ - Reference any logged-in sessions the worker should use.
186
+ - Specify the expected output shape.
187
+ - Include bail conditions: "bail if same action fails 3 times", "bail if 3 consecutive empty/error responses", "bail if single tool call exceeds 5 min".
188
+ - Autonomy split: low-stakes picks (which hashtag first, which profile to open) — let the worker decide. Irreversible actions (DM send, post, purchase) — worker must STOP and report candidates, not act. The owner confirms in chat before a second task runs the action.
185
189
 
186
190
  Over-specify. A vague description produces a vague result.
187
191
 
192
+ ### Irreversible-action split: gather → confirm → act
193
+
194
+ For tasks with an irreversible write (DM, post, purchase), split into phases:
195
+
196
+ 1. **Gather** — `[ASYNC-BROWSER: find 5 HT user candidates with German content, active 30d. Output: list of handles, follower counts, one-line notes. Do NOT send anything.]`
197
+ 2. Worker returns candidates. You present to owner: "Found A, B, C, D, E. Which?"
198
+ 3. Owner replies: "B"
199
+ 4. **Act** — `[ASYNC-BROWSER: open DM to @B, type this template: ..., send. Confirm sent.]`
200
+
201
+ Two separate tasks. Owner is in the loop between them. Never skip the confirm step on irreversible writes.
202
+
188
203
  ### Avoiding duplicates
189
204
 
190
- If you see `[Async tasks in progress]` in your preamble, a worker is already running for this chat. Do NOT emit another `[ASYNC:...]` for the same work. Reply naturally: "Still working on it, 4 minutes in."
205
+ If you see `[Async tasks in progress]` in your preamble, a worker is already running for this chat. Do NOT emit another marker for the same work. Reply naturally: "Still working on it, 4 minutes in."
191
206
 
192
207
  ## Sending files
193
208
 
@@ -207,8 +222,8 @@ Rules:
207
222
 
208
223
  ## Browser tools
209
224
 
210
- You have a Chrome browser via Playwright MCP: `browser_navigate`, `browser_take_screenshot`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_evaluate`, etc.
225
+ A shared Chrome runs on the server at `localhost:9222` with the owner's real sessions logged in (TikTok, Instagram, etc.). Playwright MCP connects to it. **You do not use the browser directly.** The browser track does — it's a parallel Claude worker with a persistent session dedicated to this Chrome.
211
226
 
212
- **Never use them inline.** All browser work goes through the async lane — see the ASYNC section above. No exceptions for "quick checks" or "just one URL". Delegate every time.
227
+ **Never call `browser_*` / `mcp__*playwright*` tools inline.** All browser work goes via `[ASYNC-BROWSER:...]`. See the two-track section above.
213
228
 
214
- To send a screenshot back from an async task: the async worker takes it with the browser tool (saving to `storage/temp/`), then includes `[IMAGE: /absolute/path.png]` in its result message.
229
+ To send a screenshot back: the browser worker takes it (saving to `storage/temp/`), then includes `[IMAGE: /absolute/path.png]` in its result message.
package/dist/ai/claude.js CHANGED
@@ -3,7 +3,7 @@ import { resolve } from 'path';
3
3
  import { config } from '../config.js';
4
4
  import { logger } from '../logger.js';
5
5
  import { logPrompt } from '../promptlog.js';
6
- import { runClaude, TIMEOUT_MS } from './spawn.js';
6
+ import { parseStreamJson, runClaude, TIMEOUT_MS } from './spawn.js';
7
7
  let cachedSystemPrompt = null;
8
8
  function systemPrompt() {
9
9
  if (cachedSystemPrompt !== null)
@@ -25,10 +25,14 @@ export function reloadSystemPrompt() {
25
25
  cachedSystemPrompt = null;
26
26
  }
27
27
  function buildArgs(params) {
28
+ // stream-json gives per-event visibility into the agent loop (system init,
29
+ // assistant messages, tool_use, tool_result, final result). We parse the
30
+ // final 'result' event for the return shape, and log event types for
31
+ // diagnostic purposes.
28
32
  const args = [
29
33
  '-p',
30
34
  '--output-format',
31
- config.claude.outputFormat,
35
+ 'stream-json',
32
36
  '--model',
33
37
  config.claude.model,
34
38
  '--permission-mode',
@@ -57,35 +61,32 @@ function buildArgs(params) {
57
61
  export async function askClaude(params) {
58
62
  const args = buildArgs(params);
59
63
  logger.debug({ resume: !!params.sessionId, inputChars: params.input.length }, 'spawning claude');
60
- const { stdout, durationMs } = await runClaude({
64
+ const { stdout, stderr, durationMs } = await runClaude({
61
65
  args,
62
66
  input: params.input,
63
67
  timeoutMs: TIMEOUT_MS.main,
64
68
  caller: 'worker',
65
69
  });
66
70
  const startedAt = Date.now() - durationMs;
67
- let parsed;
68
- try {
69
- parsed = JSON.parse(stdout);
70
- }
71
- catch (err) {
72
- throw new Error(`failed to parse claude output: ${err.message}\nstdout: ${stdout.slice(0, 500)}`);
71
+ const parsed = parseStreamJson(stdout);
72
+ if (!parsed) {
73
+ throw new Error(`claude stream-json produced no result event; stdout: ${stdout.slice(0, 500)}`);
73
74
  }
74
- if (parsed.is_error || parsed.subtype !== 'success') {
75
- throw new Error(`claude returned error (subtype=${parsed.subtype}): ${parsed.result ?? ''}`);
75
+ if (parsed.isError || parsed.subtype !== 'success') {
76
+ throw new Error(`claude returned error (subtype=${parsed.subtype}): ${parsed.result}`);
76
77
  }
77
- if (!parsed.result || !parsed.session_id) {
78
+ if (!parsed.result || !parsed.sessionId) {
78
79
  throw new Error(`claude output missing result or session_id: ${stdout.slice(0, 200)}`);
79
80
  }
80
81
  const result = {
81
82
  reply: parsed.result,
82
- sessionId: parsed.session_id,
83
+ sessionId: parsed.sessionId,
83
84
  usage: {
84
85
  inputTokens: parsed.usage?.input_tokens ?? 0,
85
86
  cacheReadTokens: parsed.usage?.cache_read_input_tokens ?? 0,
86
87
  cacheCreationTokens: parsed.usage?.cache_creation_input_tokens ?? 0,
87
88
  outputTokens: parsed.usage?.output_tokens ?? 0,
88
- numTurns: parsed.num_turns ?? 0,
89
+ numTurns: parsed.numTurns ?? 0,
89
90
  },
90
91
  };
91
92
  void logPrompt({
@@ -97,6 +98,8 @@ export async function askClaude(params) {
97
98
  sessionId: result.sessionId,
98
99
  usage: result.usage,
99
100
  durationMs,
101
+ stderr,
102
+ eventTypes: parsed.eventTypes,
100
103
  });
101
104
  return result;
102
105
  }
package/dist/ai/spawn.js CHANGED
@@ -21,6 +21,60 @@ export class ClaudeSpawnError extends Error {
21
21
  this.name = 'ClaudeSpawnError';
22
22
  }
23
23
  }
24
+ // Parse Claude CLI's --output-format stream-json output. Each line is a JSON
25
+ // event; the final event with type === 'result' carries the completion
26
+ // summary (same shape as the old single-json output format). Returns null if
27
+ // no result event is found — caller should treat that as an error.
28
+ export function parseStreamJson(stdout) {
29
+ const events = [];
30
+ const eventTypes = [];
31
+ const lines = stdout.split(/\r?\n/);
32
+ for (const line of lines) {
33
+ const trimmed = line.trim();
34
+ if (!trimmed)
35
+ continue;
36
+ try {
37
+ const parsed = JSON.parse(trimmed);
38
+ events.push(parsed);
39
+ if (typeof parsed.type === 'string')
40
+ eventTypes.push(parsed.type);
41
+ }
42
+ catch {
43
+ // Ignore malformed lines — Claude CLI occasionally emits preamble or
44
+ // debug lines that aren't JSON; the structured events we need are
45
+ // always well-formed.
46
+ }
47
+ }
48
+ // Find the final result event. Walk from end to handle any stray events
49
+ // after 'result' (shouldn't happen but be defensive).
50
+ let resultEvent = null;
51
+ for (let i = events.length - 1; i >= 0; i--) {
52
+ if (events[i].type === 'result') {
53
+ resultEvent = events[i];
54
+ break;
55
+ }
56
+ }
57
+ if (!resultEvent)
58
+ return null;
59
+ return {
60
+ result: typeof resultEvent.result === 'string' ? resultEvent.result : '',
61
+ sessionId: typeof resultEvent.session_id === 'string'
62
+ ? resultEvent.session_id
63
+ : null,
64
+ usage: resultEvent.usage && typeof resultEvent.usage === 'object'
65
+ ? resultEvent.usage
66
+ : undefined,
67
+ isError: !!resultEvent.is_error,
68
+ subtype: typeof resultEvent.subtype === 'string'
69
+ ? resultEvent.subtype
70
+ : undefined,
71
+ numTurns: typeof resultEvent.num_turns === 'number'
72
+ ? resultEvent.num_turns
73
+ : undefined,
74
+ eventTypes,
75
+ events,
76
+ };
77
+ }
24
78
  // Kill the process group of a detached child. Playwright MCP and any Chromium
25
79
  // children sit under the claude subprocess; without process-group kill they
26
80
  // linger after we SIGTERM the parent and accumulate on the host.
@@ -54,6 +108,15 @@ export async function runClaude(opts) {
54
108
  // detached:true puts the child in its own process group, so killGroup
55
109
  // can SIGTERM the whole tree (Playwright MCP, Chromium, etc.) at once.
56
110
  detached: true,
111
+ // ANTHROPIC_LOG=debug surfaces the SDK's HTTP layer to stderr:
112
+ // request URLs, status codes, retries, rate-limit notices. We
113
+ // capture stderr and put a truncated copy into the promptlog so
114
+ // we can diagnose API hangs/rate-limits post-mortem instead of
115
+ // staring at "Claude subprocess is idle, why?".
116
+ env: {
117
+ ...process.env,
118
+ ANTHROPIC_LOG: process.env.ANTHROPIC_LOG ?? 'debug',
119
+ },
57
120
  });
58
121
  let stdout = '';
59
122
  let stderr = '';
@@ -106,7 +169,7 @@ export async function runClaude(opts) {
106
169
  logFail(`exit ${code}: ${stderr.slice(0, 500)}`);
107
170
  return rejectPromise(new ClaudeSpawnError(caller, `claude exited with code ${code}: ${stderr.slice(0, 500)}`));
108
171
  }
109
- resolvePromise({ stdout, durationMs });
172
+ resolvePromise({ stdout, stderr, durationMs });
110
173
  });
111
174
  child.stdin.write(input);
112
175
  child.stdin.end();
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync, writeFileSync, } from 'fs';
2
2
  import { dirname, resolve } from 'path';
3
3
  import { mkdirSync } from 'fs';
4
- import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
4
+ import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
5
5
  import { config } from '../config.js';
6
6
  import { logger } from '../logger.js';
7
7
  import { logPrompt } from '../promptlog.js';
@@ -224,28 +224,25 @@ async function spawnGenerator(prompt) {
224
224
  const args = [
225
225
  '-p',
226
226
  '--output-format',
227
- 'json',
227
+ 'stream-json',
228
228
  '--model',
229
229
  config.claude.model,
230
230
  '--permission-mode',
231
231
  'acceptEdits',
232
232
  ];
233
- const { stdout, durationMs } = await runClaude({
233
+ const { stdout, stderr, durationMs } = await runClaude({
234
234
  args,
235
235
  input: prompt,
236
236
  timeoutMs: TIMEOUT_MS.background,
237
237
  caller: 'compressed',
238
238
  });
239
239
  const startedAt = Date.now() - durationMs;
240
- let parsed;
241
- try {
242
- parsed = JSON.parse(stdout);
243
- }
244
- catch (err) {
245
- throw new Error(`compressed parse failed: ${err.message}`);
240
+ const parsed = parseStreamJson(stdout);
241
+ if (!parsed) {
242
+ throw new Error(`compressed stream-json produced no result event: ${stdout.slice(0, 200)}`);
246
243
  }
247
- if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
248
- throw new Error(`compressed bad output: ${parsed.result ?? stdout.slice(0, 200)}`);
244
+ if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
245
+ throw new Error(`compressed bad output: ${parsed.result || stdout.slice(0, 200)}`);
249
246
  }
250
247
  const output = parsed.result.trim();
251
248
  void logPrompt({
@@ -255,6 +252,8 @@ async function spawnGenerator(prompt) {
255
252
  input: prompt,
256
253
  output,
257
254
  durationMs,
255
+ stderr,
256
+ eventTypes: parsed.eventTypes,
258
257
  });
259
258
  return output;
260
259
  }
@@ -8,7 +8,13 @@
8
8
  // drops it entirely. That bug leaked two real markers into user-facing
9
9
  // replies today (DIGEST ~morning, ASYNC later). This parser closes that
10
10
  // whole class of failure.
11
- const KINDS = ['DIGEST', 'JOURNAL', 'JOURNAL-NEW', 'ASYNC'];
11
+ const KINDS = [
12
+ 'DIGEST',
13
+ 'JOURNAL',
14
+ 'JOURNAL-NEW',
15
+ 'ASYNC',
16
+ 'ASYNC-BROWSER',
17
+ ];
12
18
  // Walk backwards from the end of the string, tracking bracket depth, to find
13
19
  // the `[` that matches the final `]`. Returns the tag kind, its payload, and
14
20
  // everything before the tag. Returns null if the tail doesn't cleanly look
@@ -51,9 +57,11 @@ function peelTrailingTag(raw) {
51
57
  }
52
58
  // Peel trailing tags off the end of a reply. Supported:
53
59
  // [DIGEST: <reason>]
54
- // [JOURNAL:<slug> — <note>] (append entry)
55
- // [JOURNAL-NEW:<slug> — <purpose>] (create journal)
56
- // [ASYNC: <self-sufficient task description>]
60
+ // [JOURNAL:<slug> — <note>] (append entry)
61
+ // [JOURNAL-NEW:<slug> — <purpose>] (create journal)
62
+ // [ASYNC: <self-sufficient task description>] (general async lane)
63
+ // [ASYNC-BROWSER: <self-sufficient task description>] (browser lane,
64
+ // serialized, 1)
57
65
  // Multiple tags supported in any order at the tail. Tags must be the LAST
58
66
  // thing in the reply (after trimming trailing whitespace).
59
67
  //
@@ -65,6 +73,7 @@ export function extractFlags(reply) {
65
73
  const journals = [];
66
74
  const journalCreates = [];
67
75
  const asyncTasks = [];
76
+ const asyncBrowserTasks = [];
68
77
  while (true) {
69
78
  const peeled = peelTrailingTag(current);
70
79
  if (!peeled)
@@ -91,8 +100,20 @@ export function extractFlags(reply) {
91
100
  asyncTasks.unshift({ description: payload });
92
101
  }
93
102
  }
103
+ else if (kind === 'ASYNC-BROWSER') {
104
+ if (payload.length >= 8) {
105
+ asyncBrowserTasks.unshift({ description: payload });
106
+ }
107
+ }
94
108
  }
95
- return { clean: current, digest, journals, journalCreates, asyncTasks };
109
+ return {
110
+ clean: current,
111
+ digest,
112
+ journals,
113
+ journalCreates,
114
+ asyncTasks,
115
+ asyncBrowserTasks,
116
+ };
96
117
  }
97
118
  // Legacy helper kept so existing callers still compile.
98
119
  export function extractDigestFlag(reply) {
@@ -1,4 +1,4 @@
1
- import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
1
+ import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
2
2
  import { config } from '../config.js';
3
3
  import { logger } from '../logger.js';
4
4
  import { logPrompt } from '../promptlog.js';
@@ -13,28 +13,25 @@ async function spawnDigester(prompt) {
13
13
  const args = [
14
14
  '-p',
15
15
  '--output-format',
16
- 'json',
16
+ 'stream-json',
17
17
  '--model',
18
18
  config.claude.model,
19
19
  '--permission-mode',
20
20
  'acceptEdits',
21
21
  ];
22
- const { stdout, durationMs } = await runClaude({
22
+ const { stdout, stderr, durationMs } = await runClaude({
23
23
  args,
24
24
  input: prompt,
25
25
  timeoutMs: TIMEOUT_MS.background,
26
26
  caller: 'digester',
27
27
  });
28
28
  const startedAt = Date.now() - durationMs;
29
- let parsed;
30
- try {
31
- parsed = JSON.parse(stdout);
32
- }
33
- catch (err) {
34
- throw new Error(`digester parse failed: ${err.message}`);
29
+ const parsed = parseStreamJson(stdout);
30
+ if (!parsed) {
31
+ throw new Error(`digester stream-json produced no result event: ${stdout.slice(0, 200)}`);
35
32
  }
36
- if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
37
- throw new Error(`digester bad output: ${parsed.result ?? stdout.slice(0, 200)}`);
33
+ if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
34
+ throw new Error(`digester bad output: ${parsed.result || stdout.slice(0, 200)}`);
38
35
  }
39
36
  const output = parsed.result.trim();
40
37
  void logPrompt({
@@ -44,6 +41,8 @@ async function spawnDigester(prompt) {
44
41
  input: prompt,
45
42
  output,
46
43
  durationMs,
44
+ stderr,
45
+ eventTypes: parsed.eventTypes,
47
46
  });
48
47
  return output;
49
48
  }
@@ -1,4 +1,4 @@
1
- import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
1
+ import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
2
2
  import { config } from '../config.js';
3
3
  import { initiate } from '../gateway/outgoing.js';
4
4
  import { logger } from '../logger.js';
@@ -18,28 +18,25 @@ async function spawnComposer(prompt) {
18
18
  const args = [
19
19
  '-p',
20
20
  '--output-format',
21
- 'json',
21
+ 'stream-json',
22
22
  '--model',
23
23
  config.claude.model,
24
24
  '--permission-mode',
25
25
  'acceptEdits',
26
26
  ];
27
- const { stdout, durationMs } = await runClaude({
27
+ const { stdout, stderr, durationMs } = await runClaude({
28
28
  args,
29
29
  input: prompt,
30
30
  timeoutMs: TIMEOUT_MS.background,
31
31
  caller: 'journal-nudger',
32
32
  });
33
33
  const startedAt = Date.now() - durationMs;
34
- let parsed;
35
- try {
36
- parsed = JSON.parse(stdout);
37
- }
38
- catch (err) {
39
- throw new Error(`nudger parse failed: ${err.message}`);
34
+ const parsed = parseStreamJson(stdout);
35
+ if (!parsed) {
36
+ throw new Error(`nudger stream-json produced no result event: ${stdout.slice(0, 200)}`);
40
37
  }
41
- if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
42
- throw new Error(`nudger bad output: ${parsed.result ?? stdout.slice(0, 200)}`);
38
+ if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
39
+ throw new Error(`nudger bad output: ${parsed.result || stdout.slice(0, 200)}`);
43
40
  }
44
41
  const output = parsed.result.trim();
45
42
  void logPrompt({
@@ -49,6 +46,8 @@ async function spawnComposer(prompt) {
49
46
  input: prompt,
50
47
  output,
51
48
  durationMs,
49
+ stderr,
50
+ eventTypes: parsed.eventTypes,
52
51
  });
53
52
  return output;
54
53
  }
@@ -1,4 +1,4 @@
1
- import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
1
+ import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
2
2
  import { config } from '../config.js';
3
3
  import { logger } from '../logger.js';
4
4
  import { logPrompt } from '../promptlog.js';
@@ -14,28 +14,25 @@ async function spawnObserver(prompt) {
14
14
  const args = [
15
15
  '-p',
16
16
  '--output-format',
17
- 'json',
17
+ 'stream-json',
18
18
  '--model',
19
19
  config.claude.model,
20
20
  '--permission-mode',
21
21
  'acceptEdits',
22
22
  ];
23
- const { stdout, durationMs } = await runClaude({
23
+ const { stdout, stderr, durationMs } = await runClaude({
24
24
  args,
25
25
  input: prompt,
26
26
  timeoutMs: TIMEOUT_MS.background,
27
27
  caller: 'journal-observer',
28
28
  });
29
29
  const startedAt = Date.now() - durationMs;
30
- let parsed;
31
- try {
32
- parsed = JSON.parse(stdout);
33
- }
34
- catch (err) {
35
- throw new Error(`journal observer parse failed: ${err.message}`);
30
+ const parsed = parseStreamJson(stdout);
31
+ if (!parsed) {
32
+ throw new Error(`journal observer stream-json produced no result event: ${stdout.slice(0, 200)}`);
36
33
  }
37
- if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
38
- throw new Error(`journal observer bad output: ${parsed.result ?? stdout.slice(0, 200)}`);
34
+ if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
35
+ throw new Error(`journal observer bad output: ${parsed.result || stdout.slice(0, 200)}`);
39
36
  }
40
37
  const output = parsed.result.trim();
41
38
  void logPrompt({
@@ -45,6 +42,8 @@ async function spawnObserver(prompt) {
45
42
  input: prompt,
46
43
  output,
47
44
  durationMs,
45
+ stderr,
46
+ eventTypes: parsed.eventTypes,
48
47
  });
49
48
  return output;
50
49
  }
@@ -10,7 +10,7 @@ import { ensureScaffold } from './store.js';
10
10
  import { getRoleForContext } from '../wa/whitelist.js';
11
11
  const DIGEST_REMINDER = `When something worth remembering happens (new preference, key fact, life event, changed plan), append [DIGEST: <one-line reason>] to the END of your reply. It will be stripped before sending. Flag sparingly.`;
12
12
  const JOURNAL_REMINDER = `When a message contains info for one of the journals above, append [JOURNAL:<slug> — <one-line note>] to the END of your reply. Multiple tags OK. Only use slugs listed; never invent. Full rules are in your memory instructions.`;
13
- const ASYNC_REMINDER = `BROWSER / SCRAPE / MULTI-STEP RESEARCH = always async. Never call browser tools (browser_navigate, browser_click, browser_take_screenshot, browser_evaluate, any mcp__*playwright*) inlinethey block this chat for minutes when pages hang. Instead: send a short ack ("On it, will report back.") AND append [ASYNC: <self-sufficient task description>] at the END of your reply. The async worker has full browser access and will report back here. Even for "just one URL" — always async.`;
13
+ const ASYNC_REMINDER = `TWO TRACKS run in parallel: you are the chat track, a separate browser track runs a persistent Claude session dedicated to the shared Chrome at localhost:9222. Never call browser tools (browser_*, mcp__*playwright*) yourselfdelegate via [ASYNC-BROWSER: <self-sufficient task description>] at the END of your reply, plus a short ack ("On it, will report back."). For non-browser long work (>30s, multi-step reasoning) use [ASYNC: ...]. Irreversible actions (DM send, post, purchase) split into gather→confirm→act phases never send on your own judgment.`;
14
14
  function buildCriticalSection(params) {
15
15
  const { senderNumber, roleName, role, userName } = params;
16
16
  const who = userName
@@ -59,9 +59,8 @@ export function buildMemoryPreamble(params) {
59
59
  ' [AUDIO: /absolute/path/to/file.mp3]\n' +
60
60
  ' [DOCUMENT: /absolute/path/to/file.pdf]\n' +
61
61
  'The tag will be stripped from the message. Use absolute paths only.\n\n' +
62
- 'Browser (Playwright MCP): a real Chrome browser is available for navigation, clicks, forms, screenshots, page content. ' +
63
- 'DO NOT call browser tools inline from this main chat lane they block the chat queue for minutes when pages stall (login walls, anti-bot, rate limits). ' +
64
- 'ALL browser work goes through the async lane. When a request needs browser: send a short ack AND append [ASYNC: <self-sufficient task description>] at the END of your reply. The async worker has full browser access; it will send the result back to this chat as a new message. Even a single URL check goes async. No exceptions.\n\n' +
62
+ 'Browser (Playwright MCP): a real Chrome at localhost:9222 with the owner\'s sessions logged in (TikTok, Instagram, etc.). DO NOT call browser tools yourself — they belong to the BROWSER TRACK, a parallel Claude worker with its own persistent session on that Chrome. ' +
63
+ 'When a request needs browser work: send a short ack AND append [ASYNC-BROWSER: <self-sufficient task description>] at the END of your reply. The browser worker picks it up, does the work in the logged-in Chrome, sends the result back to this chat as a new message. Single URL, quick check, full scrape — all go via [ASYNC-BROWSER:...]. No exceptions.\n\n' +
65
64
  'File storage: if you need to save any files (screenshots, research, notes), always save them to storage/temp/. Never save files to the project root.');
66
65
  // Critical section
67
66
  sections.push(buildCriticalSection({
package/dist/promptlog.js CHANGED
@@ -1,6 +1,21 @@
1
1
  import { appendFile, mkdir, readdir, unlink } from 'fs/promises';
2
2
  import { resolve } from 'path';
3
3
  import { config } from './config.js';
4
+ // Hard caps on fields that can grow unbounded. Prevents promptlog entries
5
+ // from exploding when a run produces huge stdout/stderr. prunePrompts
6
+ // still handles multi-day retention separately.
7
+ const STDOUT_MAX_BYTES = 100_000;
8
+ const STDERR_MAX_BYTES = 50_000;
9
+ const INPUT_MAX_BYTES = 200_000;
10
+ function truncateWithMarker(s, maxBytes) {
11
+ if (!s)
12
+ return s;
13
+ // Rough byte size via length — fine for mostly-ASCII prompt payloads.
14
+ if (s.length <= maxBytes)
15
+ return s;
16
+ const extra = s.length - maxBytes;
17
+ return s.slice(0, maxBytes) + `\n… [truncated ${extra} bytes]`;
18
+ }
4
19
  let dirReady = false;
5
20
  function promptsDir() {
6
21
  return resolve(process.cwd(), 'storage/prompts');
@@ -18,7 +33,17 @@ function logFilePath() {
18
33
  export async function logPrompt(entry) {
19
34
  try {
20
35
  await ensureDir();
21
- await appendFile(logFilePath(), JSON.stringify(entry) + '\n', 'utf-8');
36
+ const capped = {
37
+ ...entry,
38
+ input: truncateWithMarker(entry.input, INPUT_MAX_BYTES),
39
+ };
40
+ if (entry.output !== undefined) {
41
+ capped.output = truncateWithMarker(entry.output, STDOUT_MAX_BYTES);
42
+ }
43
+ if (entry.stderr !== undefined) {
44
+ capped.stderr = truncateWithMarker(entry.stderr, STDERR_MAX_BYTES);
45
+ }
46
+ await appendFile(logFilePath(), JSON.stringify(capped) + '\n', 'utf-8');
22
47
  }
23
48
  catch {
24
49
  // logging must never break the main flow
@@ -1,6 +1,6 @@
1
- import { readFileSync } from 'fs';
2
- import { resolve } from 'path';
3
- import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, resolve } from 'path';
3
+ import { parseStreamJson, runClaude, TIMEOUT_MS } from '../ai/spawn.js';
4
4
  import { config } from '../config.js';
5
5
  import fastq from 'fastq';
6
6
  import { initiate } from '../gateway/outgoing.js';
@@ -113,7 +113,7 @@ function buildArgs(task) {
113
113
  const args = [
114
114
  '-p',
115
115
  '--output-format',
116
- 'json',
116
+ 'stream-json',
117
117
  '--model',
118
118
  config.claude.model,
119
119
  '--permission-mode',
@@ -135,22 +135,19 @@ function buildArgs(task) {
135
135
  }
136
136
  async function spawnClaudeForTask(task, prompt) {
137
137
  const args = buildArgs(task);
138
- const { stdout, durationMs } = await runClaude({
138
+ const { stdout, stderr, durationMs } = await runClaude({
139
139
  args,
140
140
  input: prompt,
141
141
  timeoutMs: TIMEOUT_MS.async,
142
142
  caller: 'async-task',
143
143
  });
144
144
  const startedAt = Date.now() - durationMs;
145
- let parsed;
146
- try {
147
- parsed = JSON.parse(stdout);
148
- }
149
- catch (err) {
150
- throw new Error(`async task parse failed: ${err.message}`);
145
+ const parsed = parseStreamJson(stdout);
146
+ if (!parsed) {
147
+ throw new Error(`async task stream-json produced no result event: ${stdout.slice(0, 200)}`);
151
148
  }
152
- if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
153
- throw new Error(`async task bad output: ${parsed.result ?? stdout.slice(0, 200)}`);
149
+ if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
150
+ throw new Error(`async task bad output: ${parsed.result || stdout.slice(0, 200)}`);
154
151
  }
155
152
  const output = parsed.result.trim();
156
153
  void logPrompt({
@@ -160,6 +157,8 @@ async function spawnClaudeForTask(task, prompt) {
160
157
  input: prompt,
161
158
  output,
162
159
  durationMs,
160
+ stderr,
161
+ eventTypes: parsed.eventTypes,
163
162
  });
164
163
  return output;
165
164
  }
@@ -275,3 +274,293 @@ function titleCaseSlug(slug) {
275
274
  function truncate(s, n) {
276
275
  return s.length > n ? s.slice(0, n - 1) + '…' : s;
277
276
  }
277
+ // ============================================================================
278
+ // BROWSER LANE
279
+ // ============================================================================
280
+ // A second async lane dedicated to browser work. Key differences vs the
281
+ // general async lane above:
282
+ //
283
+ // - Concurrency is 1. Serialized against itself because (a) the shared
284
+ // Playwright MCP + Chrome is one physical resource, (b) the session below
285
+ // is persistent and --resume doesn't allow concurrent resumes.
286
+ // - One GLOBAL persistent session stored at storage/browser-session.json.
287
+ // First browser task bootstraps fresh (captures sessionId). Subsequent
288
+ // tasks spawn with --resume <sessionId>, so the browser Claude carries
289
+ // memory of prior tasks across runs.
290
+ // - Task description is added as a new user message to the persistent
291
+ // session. The worker sees the accumulated history automatically.
292
+ function browserSessionFilePath() {
293
+ return resolve(process.cwd(), config.memory.dir, 'browser-session.json');
294
+ }
295
+ function loadBrowserSession() {
296
+ const path = browserSessionFilePath();
297
+ if (!existsSync(path)) {
298
+ return { sessionId: null, createdAt: 0, lastUsedAt: 0, resumeCount: 0 };
299
+ }
300
+ try {
301
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
302
+ return {
303
+ sessionId: parsed.sessionId ?? null,
304
+ createdAt: parsed.createdAt ?? 0,
305
+ lastUsedAt: parsed.lastUsedAt ?? 0,
306
+ resumeCount: parsed.resumeCount ?? 0,
307
+ };
308
+ }
309
+ catch {
310
+ return { sessionId: null, createdAt: 0, lastUsedAt: 0, resumeCount: 0 };
311
+ }
312
+ }
313
+ function saveBrowserSession(state) {
314
+ const path = browserSessionFilePath();
315
+ mkdirSync(dirname(path), { recursive: true });
316
+ writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
317
+ }
318
+ // Reset the browser session. Callable from outside if the session gets
319
+ // corrupted or we want a fresh start. Not wired into any command yet.
320
+ export function resetBrowserSession() {
321
+ saveBrowserSession({
322
+ sessionId: null,
323
+ createdAt: 0,
324
+ lastUsedAt: 0,
325
+ resumeCount: 0,
326
+ });
327
+ logger.info('browser session reset');
328
+ }
329
+ const browserQueue = fastq.promise(async (task) => {
330
+ inProgress.set(task.id, task);
331
+ try {
332
+ await runBrowserTask(task);
333
+ }
334
+ catch (err) {
335
+ logger.error({ err, id: task.id, jid: task.jid }, 'browser task failed unexpectedly');
336
+ }
337
+ finally {
338
+ inProgress.delete(task.id);
339
+ }
340
+ }, 1);
341
+ export function enqueueBrowserTask(input) {
342
+ const task = {
343
+ ...input,
344
+ id: `browser-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
345
+ startedAt: Math.floor(Date.now() / 1000),
346
+ };
347
+ logger.info({
348
+ id: task.id,
349
+ jid: task.jid,
350
+ description: task.description.slice(0, 200),
351
+ }, 'browser task enqueued');
352
+ browserQueue.push(task).catch((err) => logger.error({ err, id: task.id }, 'browser queue push failed'));
353
+ return task;
354
+ }
355
+ function buildBrowserPrompt(task, isResume) {
356
+ // Framing tuned for the dedicated browser worker.
357
+ const lines = [
358
+ isResume
359
+ ? `You are the BROWSER WORKER. Another task just came in. You already have memory of prior browser tasks in this session — act on it accordingly. Use the shared Chrome at localhost:9222 via Playwright MCP (already logged into the owner's sessions like TikTok, Instagram, etc. — do NOT log out, do NOT start a new browser instance).`
360
+ : `You are the BROWSER WORKER. You run in a persistent session dedicated to browser tasks for the owner. The chat already got its ack; your output IS the follow-up chat reply the owner is waiting for. Use the shared Chrome at localhost:9222 via Playwright MCP (already authenticated with the owner's sessions — TikTok, Instagram, etc. — do NOT log out, do NOT launch a new browser).`,
361
+ ``,
362
+ `TASK:`,
363
+ task.description,
364
+ ``,
365
+ `ORIGINAL USER MESSAGE (for reference):`,
366
+ task.originatingMessage,
367
+ ``,
368
+ `Sender: ${task.senderName ?? task.senderNumber}`,
369
+ ``,
370
+ `HOW TO OUTPUT:`,
371
+ `- Write the full answer as a natural chat reply. Same voice as the main chat Claude, just delayed.`,
372
+ `- Open with a short "about the X you asked about..." reference — the owner may have asked for several things.`,
373
+ `- Concrete findings only. Numbers, names, dates. If you found 10 creators, list them.`,
374
+ `- Failure mode: page hung, login wall, bot-detection, empty feed — say so briefly. Do NOT fabricate.`,
375
+ ``,
376
+ `BAIL CONDITIONS (stop and report, don't burn the clock):`,
377
+ `- Same tool call with same args retried 3 times → stuck, bail.`,
378
+ `- 3 consecutive empty/error responses from the site → site is throttling, bail.`,
379
+ `- Any single tool call running past 5 min → bail.`,
380
+ `- Autonomy: pick and proceed on low-stakes choices (which hashtag first, which profile to open). For IRREVERSIBLE writes (DM send, post, purchase), do NOT act — stop and report candidates so the owner can confirm in chat.`,
381
+ ``,
382
+ `OPTIONAL MARKERS (at the END of your output):`,
383
+ `- [JOURNAL:<slug> — <one-line finding>] per finding that belongs in an active journal.`,
384
+ `- [JOURNAL-NEW:<slug> — <purpose>] if a clearly-recurring tracking surface doesn't have a journal yet.`,
385
+ `- [DIGEST: <reason>] if a durable fact about the owner/chat came up.`,
386
+ ``,
387
+ `CONSTRAINTS:`,
388
+ `- Do NOT emit [ASYNC:...] or [ASYNC-BROWSER:...]. No recursion.`,
389
+ `- Markers are bonus persistence, not a substitute for the reply.`,
390
+ `- Stay fully in character (personality).`,
391
+ ``,
392
+ `Do the work. Write the reply. Markers optional at the end.`,
393
+ ];
394
+ return lines.join('\n');
395
+ }
396
+ function buildBrowserArgs(task, sessionId) {
397
+ const args = [
398
+ '-p',
399
+ '--output-format',
400
+ 'stream-json',
401
+ '--model',
402
+ config.claude.model,
403
+ '--permission-mode',
404
+ 'acceptEdits',
405
+ ];
406
+ if (sessionId) {
407
+ // Resume — system prompt and memory-dirs are already baked into session
408
+ args.push('--resume', sessionId);
409
+ }
410
+ else {
411
+ // First call — bootstrap the persistent session
412
+ args.push('--append-system-prompt', systemPrompt());
413
+ for (const dir of config.claude.addDirs) {
414
+ args.push('--add-dir', resolve(process.cwd(), dir));
415
+ }
416
+ }
417
+ // Memory + media dirs re-added each call (harmless if already baked; needed
418
+ // on fresh bootstrap; lets the browser worker Read updated memory files
419
+ // between turns).
420
+ args.push('--add-dir', resolve(process.cwd(), config.memory.dir));
421
+ args.push('--add-dir', resolve(process.cwd(), config.storage.mediaDir));
422
+ if (task.allowedTools &&
423
+ task.allowedTools !== 'all' &&
424
+ task.allowedTools.length > 0) {
425
+ args.push('--allowedTools', task.allowedTools.join(','));
426
+ }
427
+ return args;
428
+ }
429
+ async function runBrowserTask(task) {
430
+ const session = loadBrowserSession();
431
+ const isResume = !!session.sessionId;
432
+ const prompt = buildBrowserPrompt(task, isResume);
433
+ const args = buildBrowserArgs(task, session.sessionId);
434
+ const startedAtMs = Date.now();
435
+ const elapsedLog = () => `${Math.round((Date.now() - task.startedAt * 1000) / 1000)}s`;
436
+ let stdout;
437
+ let stderr;
438
+ let durationMs;
439
+ try {
440
+ const result = await runClaude({
441
+ args,
442
+ input: prompt,
443
+ timeoutMs: TIMEOUT_MS.async,
444
+ caller: 'browser-task',
445
+ });
446
+ stdout = result.stdout;
447
+ stderr = result.stderr;
448
+ durationMs = result.durationMs;
449
+ }
450
+ catch (err) {
451
+ logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'browser task claude call failed');
452
+ await initiate({
453
+ jid: task.jid,
454
+ text: `Heads up: the browser task "${truncate(task.description, 80)}" failed. Ask me again and I'll retry.`,
455
+ });
456
+ return;
457
+ }
458
+ const parsed = parseStreamJson(stdout);
459
+ if (!parsed) {
460
+ logger.error({ id: task.id }, 'browser task stream-json produced no result event');
461
+ await initiate({
462
+ jid: task.jid,
463
+ text: `Heads up: the browser task "${truncate(task.description, 80)}" returned an unparseable response.`,
464
+ });
465
+ return;
466
+ }
467
+ if (parsed.isError || parsed.subtype !== 'success' || !parsed.result) {
468
+ logger.error({ id: task.id, subtype: parsed.subtype, isError: parsed.isError }, 'browser task bad output');
469
+ await initiate({
470
+ jid: task.jid,
471
+ text: `Heads up: the browser task "${truncate(task.description, 80)}" returned an error.`,
472
+ });
473
+ return;
474
+ }
475
+ // Persist the session id. On first call Claude returns the new sessionId;
476
+ // on resume it may return the same or a rotated one.
477
+ const returnedSessionId = parsed.sessionId;
478
+ if (returnedSessionId) {
479
+ const now = Math.floor(Date.now() / 1000);
480
+ saveBrowserSession({
481
+ sessionId: returnedSessionId,
482
+ createdAt: session.createdAt || now,
483
+ lastUsedAt: now,
484
+ resumeCount: (session.resumeCount ?? 0) + (isResume ? 1 : 0),
485
+ });
486
+ }
487
+ void logPrompt({
488
+ ts: Math.floor(startedAtMs / 1000),
489
+ caller: 'browser-task',
490
+ args,
491
+ input: prompt,
492
+ output: parsed.result,
493
+ sessionId: returnedSessionId ?? undefined,
494
+ durationMs,
495
+ stderr,
496
+ eventTypes: parsed.eventTypes,
497
+ });
498
+ // Route markers the same way the general async lane does.
499
+ const { extractFlags } = await import('../memory/digest-flag.js');
500
+ const { clean, digest, journals, journalCreates } = extractFlags(parsed.result);
501
+ const { appendEntry, createJournal, getJournal, isValidSlug } = await import('../memory/journals.js');
502
+ for (const op of journalCreates) {
503
+ if (!isValidSlug(op.slug))
504
+ continue;
505
+ if (getJournal(op.slug))
506
+ continue;
507
+ try {
508
+ createJournal({
509
+ slug: op.slug,
510
+ name: titleCaseSlug(op.slug),
511
+ purpose: op.purpose,
512
+ });
513
+ logger.info({ slug: op.slug, id: task.id }, 'journal created via browser task marker');
514
+ }
515
+ catch (err) {
516
+ logger.error({ err, op, id: task.id }, 'browser JOURNAL-NEW failed');
517
+ }
518
+ }
519
+ let appendedCount = 0;
520
+ for (const j of journals) {
521
+ const ok = appendEntry(j.slug, {
522
+ source: 'async',
523
+ jid: task.jid,
524
+ senderNumber: task.senderNumber,
525
+ note: j.note,
526
+ });
527
+ if (ok)
528
+ appendedCount++;
529
+ }
530
+ if (digest) {
531
+ const { scheduleDigest } = await import('../memory/scheduler.js');
532
+ scheduleDigest({
533
+ jid: task.jid,
534
+ number: task.senderNumber,
535
+ reason: digest,
536
+ });
537
+ }
538
+ const chatText = clean.trim();
539
+ if (chatText.length > 0) {
540
+ await initiate({ jid: task.jid, text: chatText });
541
+ }
542
+ else if (appendedCount > 0 ||
543
+ journalCreates.length > 0 ||
544
+ digest !== null) {
545
+ const bits = [];
546
+ if (appendedCount > 0) {
547
+ bits.push(`${appendedCount} journal ${appendedCount === 1 ? 'entry' : 'entries'}`);
548
+ }
549
+ if (journalCreates.length > 0) {
550
+ bits.push(`${journalCreates.length} journal${journalCreates.length === 1 ? '' : 's'} created`);
551
+ }
552
+ if (digest)
553
+ bits.push('digest scheduled');
554
+ await initiate({ jid: task.jid, text: `Done. ${bits.join(', ')}.` });
555
+ }
556
+ logger.info({
557
+ id: task.id,
558
+ jid: task.jid,
559
+ elapsed: elapsedLog(),
560
+ isResume,
561
+ appended: appendedCount,
562
+ createdJournals: journalCreates.length,
563
+ digestFired: !!digest,
564
+ chatSent: chatText.length,
565
+ }, 'browser task completed');
566
+ }
@@ -5,7 +5,7 @@ import { logger } from '../logger.js';
5
5
  import { extractFlags } from '../memory/digest-flag.js';
6
6
  import { appendEntry, createJournal, getJournal, isValidSlug, } from '../memory/journals.js';
7
7
  import { scheduleDigest } from '../memory/scheduler.js';
8
- import { enqueueAsyncTask } from './async-tasks.js';
8
+ import { enqueueAsyncTask, enqueueBrowserTask } from './async-tasks.js';
9
9
  function isStaleSessionError(err) {
10
10
  return (err instanceof Error &&
11
11
  err.message.includes('No conversation found'));
@@ -31,7 +31,7 @@ async function callClaude(job) {
31
31
  totalContextTokens,
32
32
  updatedAt: Math.floor(Date.now() / 1000),
33
33
  });
34
- const { clean, digest, journals, journalCreates, asyncTasks } = extractFlags(reply);
34
+ const { clean, digest, journals, journalCreates, asyncTasks, asyncBrowserTasks, } = extractFlags(reply);
35
35
  if (digest) {
36
36
  logger.info({ jid: job.jid, number: job.senderNumber, reason: digest }, 'DIGEST flag raised, scheduling');
37
37
  scheduleDigest({
@@ -74,10 +74,11 @@ async function callClaude(job) {
74
74
  logger.warn({ slug: j.slug, jid: job.jid }, 'JOURNAL flag pointed at unknown slug, dropped');
75
75
  }
76
76
  }
77
- // Async tasks: Claude delegated long work (browser scrapes, multi-step
78
- // research, etc.) to the background lane. The clean reply above is the
79
- // user-facing ack and will be sent normally. The async tasks run stateless
80
- // in their own queue and report back via initiate() when done.
77
+ // Async tasks: Claude delegated to background workers. Chat reply above
78
+ // is the user-facing ack. Two lanes:
79
+ // [ASYNC:...] general lane, stateless, concurrency 3, non-browser work
80
+ // [ASYNC-BROWSER:...] browser lane, persistent session, concurrency 1
81
+ // Both report back via initiate() when done.
81
82
  for (const t of asyncTasks) {
82
83
  enqueueAsyncTask({
83
84
  jid: job.jid,
@@ -87,6 +88,15 @@ async function callClaude(job) {
87
88
  allowedTools: job.allowedTools ?? 'all',
88
89
  });
89
90
  }
91
+ for (const t of asyncBrowserTasks) {
92
+ enqueueBrowserTask({
93
+ jid: job.jid,
94
+ senderNumber: job.senderNumber,
95
+ description: t.description,
96
+ originatingMessage: job.text,
97
+ allowedTools: job.allowedTools ?? 'all',
98
+ });
99
+ }
90
100
  return {
91
101
  reply: clean,
92
102
  stats: {
@@ -99,7 +109,7 @@ async function callClaude(job) {
99
109
  fresh: wasFresh,
100
110
  hasDigest: digest !== null,
101
111
  journalSlugs: journals.map((j) => j.slug),
102
- asyncCount: asyncTasks.length,
112
+ asyncCount: asyncTasks.length + asyncBrowserTasks.length,
103
113
  },
104
114
  };
105
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.7.5",
3
+ "version": "0.8.1",
4
4
  "description": "WhatsApp AI bot powered by Claude with long-term memory, browser control, and role-based access",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",