@bastani/atomic 0.5.23-0 → 0.5.24-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.
@@ -95,47 +95,38 @@ Claude maintains conversation context across calls within the same pane. Call `s
95
95
  })
96
96
  ```
97
97
 
98
- ### Advanced: Claude Agent SDK `query()` API
98
+ ### Advanced: Claude Agent SDK `query()` option surface (reference only)
99
99
 
100
- For programmatic control beyond tmux automation, the Claude Agent SDK provides `query()`. **Do not call it from inside a non-headless `ctx.stage()`** that spawns a TUI in a tmux pane that goes unused while the SDK runs in-process (see `failure-modes.md` §F17). Either set `headless: true` on the stage and pass SDK options through `s.session.query(prompt, options)` (the headless wrapper forwards them to `query()`), or use the interactive TUI route below with `chatFlags: ["--agent", "<name>", ...]` plus `s.session.query(prompt)`.
100
+ **Do not import `query` from `@anthropic-ai/claude-agent-sdk` inside a `ctx.stage()` callback.** In a non-headless stage it double-spawns Claude (idle TUI pane + in-process SDK call) see `failure-modes.md` §F16. In a headless stage it bypasses the runtime's wiring. Always go through `s.session.query()`; the runtime forwards options to the SDK for headless stages and routes the interactive TUI for non-headless stages.
101
101
 
102
- The example below is reference for the SDK option surface — in real workflow code, prefer `s.session.query()` so the runtime, not your callback, decides which transport to use:
102
+ Two correct routes:
103
103
 
104
- ```ts
105
- import { query } from "@anthropic-ai/claude-agent-sdk";
104
+ 1. **Headless + SDK options** — `s.session.query(prompt, sdkOptions)` inside `{ headless: true }`.
105
+ 2. **Interactive TUI + `--agent`** — `chatFlags: ["--agent", "<name>", ...]` in `clientOpts`.
106
106
 
107
- .run(async (ctx) => {
108
- await ctx.stage({ name: "implement" }, {}, {}, async (s) => {
109
- const result = query({
110
- prompt: (s.inputs.prompt ?? ""),
111
- options: {
112
- model: "claude-opus-4-6",
113
- effort: "high",
114
- maxTurns: 50,
115
- maxBudgetUsd: 5.0,
116
- permissionMode: "acceptEdits",
117
- allowedTools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"],
118
- disallowedTools: ["AskUserQuestion"],
119
- systemPrompt: "You are a senior engineer...",
120
- outputFormat: {
121
- type: "json_schema",
122
- schema: { type: "object", properties: { tasks: { type: "array", items: { type: "string" } } } },
123
- },
124
- agents: {
125
- reviewer: { description: "Review code changes", prompt: "You are a code reviewer..." },
126
- },
127
- },
128
- });
129
- for await (const message of result) {
130
- // Process streaming messages
131
- }
107
+ For the full SDK option surface, see `session-config.md` §"`query()` options".
108
+
109
+ Example workflow usage in a headless stage:
110
+
111
+ ```ts
112
+ await ctx.stage({ name: "implement", headless: true }, {}, {}, async (s) => {
113
+ const messages = await s.session.query(s.inputs.prompt ?? "", {
114
+ model: "claude-opus-4-6",
115
+ permissionMode: "acceptEdits",
116
+ allowedTools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"],
117
+ outputFormat: {
118
+ type: "json_schema",
119
+ schema: { type: "object", properties: { tasks: { type: "array", items: { type: "string" } } } },
120
+ },
132
121
  });
133
- })
122
+ s.save(s.sessionId);
123
+ return extractAssistantText(messages, 0);
124
+ });
134
125
  ```
135
126
 
136
127
  Key `query()` options:
137
128
  - `model` — model ID (`"claude-opus-4-6"`, `"claude-sonnet-4-6"`) or alias (`"opus"`, `"sonnet"`, `"haiku"`)
138
- - `effort` — reasoning effort (`"low"`, `"medium"`, `"high"`, `"max"` — `"max"` is Opus 4.6 only)
129
+ - `effort` — reasoning effort (`"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` — `"max"` is Opus 4.6/4.7 only)
139
130
  - `thinking` — thinking/reasoning config: `{ type: "adaptive" }` (default for supported models), `{ type: "enabled", budgetTokens: N }`, or `{ type: "disabled" }`
140
131
  - `maxTurns` — maximum conversation turns
141
132
  - `maxBudgetUsd` — spending cap in USD
@@ -155,43 +146,67 @@ Key `query()` options:
155
146
 
156
147
  ### Subagents
157
148
 
158
- Claude supports parallel subagents via the `agents` option (a `Record<string, AgentDefinition>` keyed by agent name):
149
+ Claude supports parallel subagents via the `agents` option (a
150
+ `Record<string, AgentDefinition>` keyed by agent name). In a workflow,
151
+ pass the option through `s.session.query(prompt, sdkOptions)` in a
152
+ **headless** stage — see §F16 for why the raw SDK `query()` import is
153
+ an anti-pattern:
159
154
 
160
155
  ```ts
161
- const agents = {
162
- worker: {
163
- description: "Implement a single task",
164
- prompt: "You are a task implementer...",
165
- tools: ["Read", "Write", "Edit", "Bash"],
166
- },
167
- reviewer: {
168
- description: "Review code changes",
169
- prompt: "You are a code reviewer...",
170
- tools: ["Read", "Grep", "Glob"],
156
+ await ctx.stage(
157
+ { name: "implement-and-review", headless: true },
158
+ {}, {},
159
+ async (s) => {
160
+ const messages = await s.session.query(
161
+ "Implement and review the feature",
162
+ {
163
+ agents: {
164
+ worker: {
165
+ description: "Implement a single task",
166
+ prompt: "You are a task implementer...",
167
+ tools: ["Read", "Write", "Edit", "Bash"],
168
+ },
169
+ reviewer: {
170
+ description: "Review code changes",
171
+ prompt: "You are a code reviewer...",
172
+ tools: ["Read", "Grep", "Glob"],
173
+ },
174
+ },
175
+ },
176
+ );
177
+ s.save(s.sessionId);
178
+ return extractAssistantText(messages, 0);
171
179
  },
172
- };
173
-
174
- const result = query({
175
- prompt: "Implement and review the feature",
176
- options: { agents },
177
- });
180
+ );
178
181
  ```
179
182
 
180
183
  ### Session continuity
181
184
 
182
- Resume or fork prior sessions:
185
+ Resume or fork prior sessions through `s.session.query()` in a headless
186
+ stage (same reasoning as Subagents above — never import `query` directly):
183
187
 
184
188
  ```ts
185
189
  // Resume a session (continues the same conversation)
186
- const result = query({ prompt: "Continue...", options: { resume: sessionId } });
190
+ await ctx.stage({ name: "continue", headless: true }, {}, {}, async (s) => {
191
+ const messages = await s.session.query("Continue...", { resume: sessionId });
192
+ s.save(s.sessionId);
193
+ return extractAssistantText(messages, 0);
194
+ });
187
195
 
188
196
  // Fork a session (creates a new branch from the session's history)
189
- const result = query({ prompt: "Try a different approach", options: { resume: sessionId, forkSession: true } });
197
+ await ctx.stage({ name: "fork", headless: true }, {}, {}, async (s) => {
198
+ const messages = await s.session.query(
199
+ "Try a different approach",
200
+ { resume: sessionId, forkSession: true },
201
+ );
202
+ s.save(s.sessionId);
203
+ return extractAssistantText(messages, 0);
204
+ });
190
205
  ```
191
206
 
192
- ### Sub-agent delegation
207
+ ### Subagent delegation
193
208
 
194
- For stages that call a single sub-agent, use `--agent` (interactive) or the SDK `agent` option (headless) to route all prompts through that agent. The agent must be defined in `.claude/agents/` or `.agents/skills/`.
209
+ For stages that call a single subagent, use `--agent` (interactive) or the SDK `agent` option (headless) to route all prompts through that agent. The agent must be defined in `.claude/agents/` or `.agents/skills/`.
195
210
 
196
211
  **Interactive stages** — pass `--agent` via `chatFlags` in client opts (2nd arg):
197
212
 
@@ -318,38 +333,45 @@ A workflow is not just a sequence of agent calls — it is an **information
318
333
  flow problem**. The single most common failure mode in Copilot workflows is
319
334
  assuming context carries across session boundaries when it doesn't.
320
335
  Designing a workflow without thinking about information flow produces
321
- sub-agents that hallucinate, repeat work, or drop requirements silently.
336
+ subagents that hallucinate, repeat work, or drop requirements silently.
322
337
 
323
338
  **Treat this section as load-bearing**, not decorative. If you skip it, your
324
339
  workflow will ship broken in subtle, non-deterministic ways.
325
340
 
326
- #### The three session lifecycle states
341
+ #### Session lifecycle states
327
342
 
328
- Every Copilot session is always in exactly one of these states, and the
343
+ For normal workflow authoring, use the **3-state rubric** from SKILL.md:
344
+ `Fresh` / `Continued` / `Closed`. Every new `ctx.stage()` call is fresh; if
345
+ you need full history, prefer another turn inside the same stage callback.
346
+
347
+ Copilot also exposes an advanced `Resumed` state at the provider level. Each
329
348
  state determines what context the model sees on its next turn:
330
349
 
331
- | State | How you get there | Context available | Action needed |
332
- |---|---|---|---|
333
- | **Fresh** | `client.createSession(...)` | **None** — empty conversation | You MUST inject everything the agent needs in the first prompt |
334
- | **Continued** | Same session, additional `send` calls | All prior turns in this session | Nothing — but watch total token usage |
335
- | **Resumed** | `client.resumeSession(sessionId)` | All persisted turns from the prior session of the SAME agent | Nothing — full history is reattached |
336
- | **Closed** | `session.disconnect()` or `client.stop()` | **Gone** from the live client; persisted on disk if the host enables it | Either resume by ID (same agent) or start fresh and re-inject context |
350
+ | State | How you get there | Context available | Action needed |
351
+ | ------------------------ | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
352
+ | **Fresh** | `client.createSession(...)` (what `ctx.stage()` does) | **None** — empty conversation | You MUST inject everything the agent needs in the first prompt |
353
+ | **Continued** | Same session, additional `send` calls | All prior turns in this session | Nothing — but watch total token usage |
354
+ | **Resumed** *(advanced)* | `client.resumeSession(sessionId)` | All persisted turns from the prior session of the SAME agent | Nothing — full history is reattached. Use only for same-role continuation |
355
+ | **Closed** | `session.disconnect()` or `client.stop()` (auto-handled by runtime after the stage callback returns) | **Gone** from the live client; persisted on disk if the host enables it | Either resume by ID (same agent) or start fresh and re-inject context |
337
356
 
338
357
  The failure mode: you close a session, create a new one, and assume the new
339
358
  one "remembers" the previous conversation. It doesn't. `client` is just the
340
- transport — each session is a fully independent conversation.
341
-
342
- For normal workflow authoring, the key rule is simpler than the full lifecycle
343
- table: **every new `ctx.stage()` call is fresh**. If you need full history,
344
- prefer another turn inside the same stage callback. Resume/fork APIs are
345
- provider-specific escape hatches, not the default stage-to-stage handoff path.
359
+ transport — each session is a fully independent conversation. Resume/fork
360
+ APIs are provider-specific escape hatches, not the default stage-to-stage
361
+ handoff path.
346
362
 
347
363
  ```ts
348
- // Buggy — the orchestrator session is fresh and knows NOTHING about
349
- // what the planner just produced, because createSession() started a
350
- // brand-new conversation.
351
- await runAgent("planner", buildPlannerPrompt((ctx.inputs.prompt ?? "")));
352
- await runAgent("orchestrator", buildOrchestratorPrompt());
364
+ // Buggy — the orchestrator stage is fresh and knows NOTHING about what
365
+ // the planner just produced, because each ctx.stage() starts a brand-new
366
+ // conversation for Copilot.
367
+ await ctx.stage({ name: "planner" }, {}, { agent: "planner" }, async (s) => {
368
+ await s.session.send({ prompt: buildPlannerPrompt((s.inputs.prompt ?? "")) });
369
+ s.save(await s.session.getMessages());
370
+ });
371
+ await ctx.stage({ name: "orchestrator" }, {}, { agent: "orchestrator" }, async (s) => {
372
+ await s.session.send({ prompt: buildOrchestratorPrompt() });
373
+ s.save(await s.session.getMessages());
374
+ });
353
375
  // ↑ orchestrator only sees buildOrchestratorPrompt() — no planner output,
354
376
  // no original user spec, no context.
355
377
  ```
@@ -361,22 +383,37 @@ mutually exclusive — ralph uses (1) + (2) together as belt-and-braces.
361
383
 
362
384
  **1. Explicit prompt handoff** — capture the prior session's last assistant
363
385
  message and inject it (or a summary) into the next session's first prompt.
364
- Simplest and most common fix:
386
+ Simplest and most common fix. Use `ctx.stage()` — the runtime auto-creates
387
+ and cleans up each session, so never call `client.createSession()` directly
388
+ (see Structural Rule 7 in SKILL.md):
365
389
 
366
390
  ```ts
367
- async function runAgent(agent: string, prompt: string): Promise<string> {
368
- const session = await client.createSession({ agent, onPermissionRequest: approveAll });
369
- await session.send({ prompt });
370
- const messages = await session.getMessages();
371
- await session.disconnect();
372
- return getAssistantText(messages); // concatenate every top-level turn — see failure-modes.md §F1
373
- }
374
-
375
391
  // Correct — forward the planner's output into the orchestrator prompt
376
- const plannerNotes = await runAgent("planner", buildPlannerPrompt((ctx.inputs.prompt ?? "")));
377
- await runAgent(
378
- "orchestrator",
379
- buildOrchestratorPrompt((ctx.inputs.prompt ?? ""), { plannerNotes }),
392
+ const plannerHandle = await ctx.stage(
393
+ { name: "planner" },
394
+ {},
395
+ { agent: "planner" },
396
+ async (s) => {
397
+ await s.session.send({ prompt: buildPlannerPrompt((s.inputs.prompt ?? "")) });
398
+ const messages = await s.session.getMessages();
399
+ s.save(messages);
400
+ return getAssistantText(messages); // see failure-modes.md §F1 for getAssistantText
401
+ },
402
+ );
403
+
404
+ await ctx.stage(
405
+ { name: "orchestrator" },
406
+ {},
407
+ { agent: "orchestrator" },
408
+ async (s) => {
409
+ await s.session.send({
410
+ prompt: buildOrchestratorPrompt(
411
+ (s.inputs.prompt ?? ""),
412
+ { plannerNotes: plannerHandle.result },
413
+ ),
414
+ });
415
+ s.save(await s.session.getMessages());
416
+ },
380
417
  );
381
418
  ```
382
419
 
@@ -427,15 +464,15 @@ trade-offs in depth.
427
464
  Information flow is a design problem, not an implementation detail. Before
428
465
  committing to a session layout, pull in the relevant skills:
429
466
 
430
- | When you're deciding... | Consult |
431
- |---|---|
432
- | What context each session actually needs (anatomy + token budget) | `context-fundamentals` |
467
+ | When you're deciding... | Consult |
468
+ | ------------------------------------------------------------------------ | ---------------------- |
469
+ | What context each session actually needs (anatomy + token budget) | `context-fundamentals` |
433
470
  | How many sessions and how they hand off (orchestrator vs peers vs swarm) | `multi-agent-patterns` |
434
- | How to compress large planner/reviewer output before re-injecting | `context-compression` |
435
- | How to detect and prevent lost-in-middle, poisoning, and distraction | `context-degradation` |
436
- | How to use files as coordination medium across sessions | `filesystem-context` |
437
- | How to persist knowledge across whole workflow runs | `memory-systems` |
438
- | Which turns to drop, which to cache, when to compact | `context-optimization` |
471
+ | How to compress large planner/reviewer output before re-injecting | `context-compression` |
472
+ | How to detect and prevent lost-in-middle, poisoning, and distraction | `context-degradation` |
473
+ | How to use files as coordination medium across sessions | `filesystem-context` |
474
+ | How to persist knowledge across whole workflow runs | `memory-systems` |
475
+ | Which turns to drop, which to cache, when to compact | `context-optimization` |
439
476
 
440
477
  These aren't optional reading — they're the difference between a workflow
441
478
  that works on day one and a workflow that silently degrades as inputs grow.
@@ -518,27 +555,17 @@ await ctx.stage(
518
555
 
519
556
  Do **not** just grab `.at(-1).data.content` — a Copilot turn's final
520
557
  `assistant.message` often has empty `content` (tool-calls-only) and
521
- sub-agent messages can pollute the stream via `parentToolCallId`. Concatenate
522
- every top-level turn's non-empty content instead. See
523
- `references/failure-modes.md` §"Copilot: `getLastAssistantText` returns
524
- empty string" for the full explanation and wrong-vs-right examples.
558
+ subagent messages can pollute the stream via `parentToolCallId`. Concatenate
559
+ every top-level turn's non-empty content instead.
560
+
561
+ The canonical `getAssistantText` helper lives in `failure-modes.md` §F1 —
562
+ copy it into a sibling `helpers/parsers.ts` and import it. Usage:
525
563
 
526
564
  ```ts
527
- import type { SessionEvent } from "@github/copilot-sdk";
528
-
529
- /** Concatenate every top-level assistant turn's non-empty content.
530
- * Canonical definition — also referenced in failure-modes.md §F1
531
- * and computation-and-validation.md. */
532
- function getAssistantText(messages: SessionEvent[]): string {
533
- return messages
534
- .filter(
535
- (m): m is Extract<SessionEvent, { type: "assistant.message" }> =>
536
- m.type === "assistant.message" && !m.data.parentToolCallId,
537
- )
538
- .map((m) => m.data.content)
539
- .filter((c) => c.length > 0)
540
- .join("\n\n");
541
- }
565
+ import { getAssistantText } from "../helpers/parsers.ts";
566
+
567
+ const messages = await s.session.getMessages();
568
+ const text = getAssistantText(messages);
542
569
  ```
543
570
 
544
571
  ### Streaming events
@@ -554,9 +581,9 @@ s.session.on("assistant.reasoning_delta", (event) => {
554
581
  });
555
582
  ```
556
583
 
557
- ### Sub-agent delegation
584
+ ### Subagent delegation
558
585
 
559
- Pass the `agent` parameter in `sessionOpts` (3rd arg to `ctx.stage()`) to bind the session to a named sub-agent:
586
+ Pass the `agent` parameter in `sessionOpts` (3rd arg to `ctx.stage()`) to bind the session to a named subagent:
560
587
 
561
588
  ```ts
562
589
  .run(async (ctx) => {
@@ -629,11 +656,11 @@ export default defineWorkflow({
629
656
 
630
657
  OpenCode sessions have **exactly the same isolation semantics as Copilot
631
658
  sessions**. Every call to `client.session.create(...)` returns a fresh,
632
- empty conversation. Creating a new session for the next sub-agent wipes
659
+ empty conversation. Creating a new session for the next subagent wipes
633
660
  everything the prior session knew — conversation history, tool-call
634
661
  results, intermediate reasoning — unless you forward it explicitly.
635
662
 
636
- The full explanation, the three lifecycle states (Fresh / Continued /
663
+ The full explanation, the four lifecycle states (Fresh / Continued /
637
664
  Resumed / Closed), the three valid ways to carry context across a session
638
665
  boundary, compaction & clearing guidance, and the context engineering
639
666
  skill-map live in the **Copilot** section above under
@@ -642,32 +669,70 @@ available"](#critical-pitfall-session-lifecycle-controls-what-context-is-availab
642
669
  Every principle there applies to OpenCode without modification — just
643
670
  substitute the OpenCode API equivalents:
644
671
 
645
- | Concept | Copilot API | OpenCode API |
646
- |---|---|---|
647
- | Fresh session (auto-created) | `s.session` (runtime creates via `createSession`) | `s.session` (runtime creates via `session.create`) |
648
- | Send a turn | `s.session.send({ prompt })` | `s.client.session.prompt({ sessionID: s.session.id, parts })` |
649
- | Close / disconnect | Auto-handled by runtime | session lifecycle managed via server; no explicit disconnect in typical flow |
650
- | Continue prior conversation | `s.client.resumeSession(sessionId)` (provider API; advanced) | Reuse the same `sessionID` with `s.client.session.prompt()` inside the same logical conversation. `ctx.stage()` itself still creates a fresh session every time |
651
- | Extract final text | `getAssistantText(messages)` (see `failure-modes.md` §F1) | `extractResponseText(result.data!.parts)` |
672
+ | Concept | Copilot API | OpenCode API |
673
+ | ---------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
674
+ | Fresh session (auto-created) | `s.session` (runtime creates via `createSession`) | `s.session` (runtime creates via `session.create`) |
675
+ | Send a turn | `s.session.send({ prompt })` | `s.client.session.prompt({ sessionID: s.session.id, parts })` |
676
+ | Close / disconnect | Auto-handled by runtime | session lifecycle managed via server; no explicit disconnect in typical flow |
677
+ | Continue prior conversation | `s.client.resumeSession(sessionId)` (provider API; advanced) | Reuse the same `sessionID` with `s.client.session.prompt()` inside the same logical conversation. `ctx.stage()` itself still creates a fresh session every time |
678
+ | Extract final text | `getAssistantText(messages)` (see `failure-modes.md` §F1) | `extractResponseText(result.data!.parts)` |
652
679
 
653
680
  **Multi-agent handoff example (applies the same pattern as Copilot):**
654
681
 
655
682
  ```ts
656
- // Buggy — orchestrator session is fresh; it has no idea what the planner
657
- // produced because we created a brand-new session for it.
658
- await runAgent("planner-1", "planner", buildPlannerPrompt((ctx.inputs.prompt ?? "")));
659
- await runAgent("orchestrator-1", "orchestrator", buildOrchestratorPrompt());
683
+ // Buggy — orchestrator stage is fresh; it has no idea what the planner
684
+ // produced because each ctx.stage() starts a brand-new session.
685
+ await ctx.stage({ name: "planner" }, {}, { title: "planner" }, async (s) => {
686
+ const result = await s.client.session.prompt({
687
+ sessionID: s.session.id,
688
+ parts: [{ type: "text", text: buildPlannerPrompt((s.inputs.prompt ?? "")) }],
689
+ agent: "planner",
690
+ });
691
+ s.save(result.data!);
692
+ });
693
+ await ctx.stage({ name: "orchestrator" }, {}, { title: "orchestrator" }, async (s) => {
694
+ await s.client.session.prompt({
695
+ sessionID: s.session.id,
696
+ parts: [{ type: "text", text: buildOrchestratorPrompt() }],
697
+ agent: "orchestrator",
698
+ });
699
+ s.save(/* ... */);
700
+ });
660
701
 
661
702
  // Correct — capture planner output and forward it into orchestrator prompt
662
- const plannerNotes = await runAgent(
663
- "planner-1",
664
- "planner",
665
- buildPlannerPrompt((ctx.inputs.prompt ?? "")),
703
+ const plannerHandle = await ctx.stage(
704
+ { name: "planner" },
705
+ {},
706
+ { title: "planner" },
707
+ async (s) => {
708
+ const result = await s.client.session.prompt({
709
+ sessionID: s.session.id,
710
+ parts: [{ type: "text", text: buildPlannerPrompt((s.inputs.prompt ?? "")) }],
711
+ agent: "planner",
712
+ });
713
+ s.save(result.data!);
714
+ return extractResponseText(result.data!.parts); // see failure-modes.md §F3
715
+ },
666
716
  );
667
- await runAgent(
668
- "orchestrator-1",
669
- "orchestrator",
670
- buildOrchestratorPrompt((ctx.inputs.prompt ?? ""), { plannerNotes }),
717
+
718
+ await ctx.stage(
719
+ { name: "orchestrator" },
720
+ {},
721
+ { title: "orchestrator" },
722
+ async (s) => {
723
+ const result = await s.client.session.prompt({
724
+ sessionID: s.session.id,
725
+ parts: [{
726
+ type: "text",
727
+ text: buildOrchestratorPrompt(
728
+ (s.inputs.prompt ?? ""),
729
+ { plannerNotes: plannerHandle.result },
730
+ ),
731
+ }],
732
+ agent: "orchestrator",
733
+ });
734
+ s.save(result.data!);
735
+ },
671
736
  );
672
737
  ```
673
738
 
@@ -751,20 +816,14 @@ const result = await s.client.session.prompt({
751
816
 
752
817
  ### Extracting response text
753
818
 
819
+ Non-text parts (`tool`, `file`, `reasoning`, …) coexist with `text` parts in
820
+ `result.data!.parts`; naive `.map(p => p.text)` emits `undefined` for them.
821
+ The canonical `extractResponseText` helper lives in `failure-modes.md` §F3 —
822
+ copy it into a sibling `helpers/parsers.ts` and import it. Usage:
823
+
754
824
  ```ts
755
- /** Filter for text parts only — non-text parts produce [object Object].
756
- * Canonical definition — also referenced in failure-modes.md §F3
757
- * and computation-and-validation.md. */
758
- function extractResponseText(
759
- parts: Array<{ type: string; [key: string]: unknown }>,
760
- ): string {
761
- return parts
762
- .filter((p) => p.type === "text")
763
- .map((p) => (p as { type: string; text: string }).text)
764
- .join("\n");
765
- }
766
-
767
- // Usage inside a ctx.stage callback:
825
+ import { extractResponseText } from "../helpers/parsers.ts";
826
+
768
827
  const result = await s.client.session.prompt({
769
828
  sessionID: s.session.id,
770
829
  parts: [{ type: "text", text: (s.inputs.prompt ?? "") }],
@@ -783,9 +842,9 @@ const unsubscribe = await s.client.event.subscribe((event) => {
783
842
  });
784
843
  ```
785
844
 
786
- ### Sub-agent delegation
845
+ ### Subagent delegation
787
846
 
788
- Pass the `agent` parameter to `s.client.session.prompt()` to route a prompt to a named sub-agent:
847
+ Pass the `agent` parameter to `s.client.session.prompt()` to route a prompt to a named subagent:
789
848
 
790
849
  ```ts
791
850
  .run(async (ctx) => {
@@ -4,12 +4,14 @@ Deterministic computation — validation, data transforms, file I/O, API calls
4
4
 
5
5
  ## Inline computation
6
6
 
7
- Any TypeScript code inside a session callback that doesn't call an SDK prompt function is deterministic computation:
7
+ Any TypeScript code inside a session callback that doesn't call an SDK prompt function is deterministic computation. Prefer handle-based lookups (`s.getMessages(plannerHandle)`) over string names when a handle is in scope — it preserves type information and survives stage renames:
8
8
 
9
9
  ```ts
10
+ const plannerHandle = await ctx.stage({ name: "planner" }, {}, {}, async (s) => { /* ... */ });
11
+
10
12
  await ctx.stage({ name: "validate-and-fix", description: "Validate, then fix if needed" }, {}, {}, async (s) => {
11
- // Step 1: Deterministic — parse prior session's output
12
- const messages = await s.getMessages("planner");
13
+ // Step 1: Deterministic — parse prior session's output (handle-based lookup)
14
+ const messages = await s.getMessages(plannerHandle);
13
15
  const planText = extractText(messages);
14
16
  const plan = JSON.parse(planText);
15
17
 
@@ -72,39 +74,12 @@ const text = extractResponseText(result.data!.parts);
72
74
 
73
75
  ### JSON parsing with fallback
74
76
 
75
- Use a layered fallback: direct parse, then the **last** fenced block (not
76
- the first — see `failure-modes.md` §F4 / §F8 for why), then last balanced
77
- object:
78
-
79
- ```ts
80
- function parseJsonResponse(text: string): Record<string, unknown> | null {
81
- // 1. Direct parse
82
- try { return JSON.parse(text); } catch {}
83
-
84
- // 2. LAST fenced code block (prose often quotes examples before the real output)
85
- const blockRe = /```(?:json)?\s*\n([\s\S]*?)\n```/g;
86
- let lastBlock: string | null = null;
87
- let m: RegExpExecArray | null;
88
- while ((m = blockRe.exec(text)) !== null) {
89
- if (m[1]) lastBlock = m[1];
90
- }
91
- if (lastBlock) {
92
- try { return JSON.parse(lastBlock); } catch {}
93
- }
94
-
95
- // 3. Last balanced JSON object
96
- const objRe = /\{[\s\S]*\}/g;
97
- let lastObj: string | null = null;
98
- while ((m = objRe.exec(text)) !== null) {
99
- if (m[0]) lastObj = m[0];
100
- }
101
- if (lastObj) {
102
- try { return JSON.parse(lastObj); } catch {}
103
- }
104
-
105
- return null;
106
- }
107
- ```
77
+ Use a layered fallback: direct parse **last** fenced block (not the
78
+ first — prose often quotes examples earlier; see `failure-modes.md` §F8)
79
+ last balanced object. Canonical helper lives in `state-and-data-flow.md`
80
+ §"Response parsers"; copy it into a sibling `helpers/parsers.ts` and
81
+ import. The full three-layer implementation, including the balanced-object
82
+ fallback, is in `src/sdk/workflows/builtin/ralph/helpers/prompts.ts`.
108
83
 
109
84
  ### Zod validation
110
85
 
@@ -171,7 +146,7 @@ Transform data between sessions:
171
146
  ```ts
172
147
  // Inside a ctx.stage() callback:
173
148
  async (s) => {
174
- const raw = await s.getMessages("planner");
149
+ const raw = await s.getMessages("planner"); // or s.getMessages(handle) if a handle is in scope (preferred)
175
150
 
176
151
  // Transform: extract only task IDs and descriptions
177
152
  const tasks = extractTasks(raw).map(t => ({