@bastani/atomic 0.5.21 → 0.5.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +0 -12
- package/dist/commands/cli/claude-stop-hook.d.ts +65 -0
- package/dist/commands/cli/claude-stop-hook.d.ts.map +1 -0
- package/dist/sdk/providers/claude.d.ts +132 -84
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/types.d.ts +4 -4
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/workflows/index.d.ts +1 -1
- package/dist/sdk/workflows/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/commands/cli/claude-stop-hook.test.ts +155 -24
- package/src/commands/cli/claude-stop-hook.ts +122 -16
- package/src/commands/cli/workflow.ts +10 -0
- package/src/sdk/providers/claude.ts +511 -290
- package/src/sdk/runtime/executor.test.ts +173 -27
- package/src/sdk/runtime/executor.ts +348 -102
- package/src/sdk/types.ts +2 -4
- package/src/sdk/workflows/index.ts +0 -1
|
@@ -557,18 +557,37 @@ function printDetachedBanner(tmuxSessionName: string): void {
|
|
|
557
557
|
);
|
|
558
558
|
}
|
|
559
559
|
|
|
560
|
-
/**
|
|
561
|
-
* Small buffer (ms) subtracted from `Date.now()` when recording the Claude
|
|
562
|
-
* session start timestamp. Protects against fast sequential runs where
|
|
563
|
-
* the system clock granularity could cause a just-created session's
|
|
564
|
-
* `lastModified` to fall slightly before our recorded timestamp.
|
|
565
|
-
*/
|
|
566
|
-
const CLAUDE_SESSION_TIMESTAMP_BUFFER_MS = 100;
|
|
567
|
-
|
|
568
560
|
// ============================================================================
|
|
569
561
|
// Session execution helpers
|
|
570
562
|
// ============================================================================
|
|
571
563
|
|
|
564
|
+
/**
|
|
565
|
+
* Resolve the provider-specific session identifier for use as
|
|
566
|
+
* `SessionContext.sessionId`:
|
|
567
|
+
* - Claude interactive: `ClaudeSessionWrapper.sessionId` — the Claude UUID
|
|
568
|
+
* set when `createClaudeSession` ran.
|
|
569
|
+
* - Claude headless: `HeadlessClaudeSessionWrapper.sessionId` — the SDK
|
|
570
|
+
* `session_id` from the most recently completed `query()` (empty string
|
|
571
|
+
* until the first query returns).
|
|
572
|
+
* - Copilot: `CopilotSession.sessionId`.
|
|
573
|
+
* - OpenCode: `Session.id`.
|
|
574
|
+
*
|
|
575
|
+
* Returns an empty string for unknown shapes rather than throwing so
|
|
576
|
+
* early-init readers of `s.sessionId` (e.g. logging) don't crash.
|
|
577
|
+
*/
|
|
578
|
+
function resolveProviderSessionId(
|
|
579
|
+
agent: AgentType,
|
|
580
|
+
providerSession: unknown,
|
|
581
|
+
): string {
|
|
582
|
+
if (!providerSession || typeof providerSession !== "object") return "";
|
|
583
|
+
const obj = providerSession as Record<string, unknown>;
|
|
584
|
+
if (agent === "opencode") {
|
|
585
|
+
return typeof obj["id"] === "string" ? (obj["id"] as string) : "";
|
|
586
|
+
}
|
|
587
|
+
// claude and copilot both expose `sessionId` as a string.
|
|
588
|
+
return typeof obj["sessionId"] === "string" ? (obj["sessionId"] as string) : "";
|
|
589
|
+
}
|
|
590
|
+
|
|
572
591
|
/** Type guard for objects with a string `content` property (Copilot assistant.message data). */
|
|
573
592
|
export function hasContent(value: unknown): value is { content: string } {
|
|
574
593
|
return (
|
|
@@ -579,56 +598,281 @@ export function hasContent(value: unknown): value is { content: string } {
|
|
|
579
598
|
);
|
|
580
599
|
}
|
|
581
600
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
601
|
+
/**
|
|
602
|
+
* Character budget cap for tool-call `input` payloads embedded in the
|
|
603
|
+
* transcript. Tool call arguments can grow (diffs, large SQL strings, whole
|
|
604
|
+
* files passed inline), and the transcript's primary consumer is a
|
|
605
|
+
* downstream LLM that must `Read` this file as context for its own turn —
|
|
606
|
+
* so we cap the per-call JSON at a predictable size. The suffix
|
|
607
|
+
* `[+N chars]` preserves the dropped length for humans reviewing the file.
|
|
608
|
+
*
|
|
609
|
+
* Tool _results_ are intentionally NOT included in the transcript. File
|
|
610
|
+
* contents, shell output, and search results inflate the transcript
|
|
611
|
+
* dramatically and lead to context rot on the next stage. A reader (human
|
|
612
|
+
* or model) can still reconstruct what the tool returned by looking at
|
|
613
|
+
* the assistant's subsequent text — which is the whole point of the
|
|
614
|
+
* assistant summarising its own work.
|
|
615
|
+
*/
|
|
616
|
+
const TRANSCRIPT_TOOL_INPUT_BUDGET = 800;
|
|
617
|
+
|
|
618
|
+
function truncateForTranscript(text: string, max: number): string {
|
|
619
|
+
if (text.length <= max) return text;
|
|
620
|
+
return text.slice(0, max) + ` … [+${text.length - max} chars]`;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Render a tool_use `input` object as a JSON-ish block, capped to budget. */
|
|
624
|
+
function renderToolInput(input: unknown): string {
|
|
625
|
+
let json: string;
|
|
626
|
+
try {
|
|
627
|
+
json = JSON.stringify(input, null, 2);
|
|
628
|
+
} catch {
|
|
629
|
+
json = String(input);
|
|
630
|
+
}
|
|
631
|
+
return truncateForTranscript(json, TRANSCRIPT_TOOL_INPUT_BUDGET);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Render a Claude transcript as readable Markdown.
|
|
636
|
+
*
|
|
637
|
+
* Captures the user/agent interaction chronologically:
|
|
638
|
+
* - User messages (string content) → `### User`
|
|
639
|
+
* - Assistant text blocks → `### Assistant`
|
|
640
|
+
* - Assistant `tool_use` blocks → `**→ \`Name\`**` + JSON input
|
|
641
|
+
*
|
|
642
|
+
* Intentionally omitted:
|
|
643
|
+
* - `tool_result` blocks — their payloads (file contents, shell output,
|
|
644
|
+
* stringified diffs) dominate the transcript and lead to context rot on
|
|
645
|
+
* the next stage. The assistant's subsequent text response already
|
|
646
|
+
* summarises what the tool returned; re-including the raw output
|
|
647
|
+
* duplicates that information at high token cost.
|
|
648
|
+
* - `thinking` blocks — verbose internal reasoning rarely useful when the
|
|
649
|
+
* transcript is re-ingested as context elsewhere.
|
|
650
|
+
* - `system` / `summary` / other non-message types.
|
|
651
|
+
*/
|
|
652
|
+
function renderClaudeTranscript(
|
|
653
|
+
messages: ReadonlyArray<{ type: string; message: unknown }>,
|
|
654
|
+
): string {
|
|
655
|
+
const sections: string[] = [];
|
|
656
|
+
|
|
657
|
+
for (const msg of messages) {
|
|
658
|
+
if (msg.type !== "user" && msg.type !== "assistant") continue;
|
|
659
|
+
|
|
660
|
+
// `message` shape is one of:
|
|
661
|
+
// - a plain string (legacy path),
|
|
662
|
+
// - `{ role, content: string }` (API-style plain text turn),
|
|
663
|
+
// - `{ role, content: Block[] }` (tool-use / tool-result turns).
|
|
664
|
+
// Normalise the first two into a single string; handle the third below.
|
|
665
|
+
const rawMessage = msg.message;
|
|
666
|
+
let plainText: string | null = null;
|
|
667
|
+
let arrayContent: unknown[] | null = null;
|
|
668
|
+
|
|
669
|
+
if (typeof rawMessage === "string") {
|
|
670
|
+
plainText = rawMessage;
|
|
671
|
+
} else if (rawMessage && typeof rawMessage === "object") {
|
|
672
|
+
const content = (rawMessage as { content?: unknown }).content;
|
|
673
|
+
if (typeof content === "string") {
|
|
674
|
+
plainText = content;
|
|
675
|
+
} else if (Array.isArray(content)) {
|
|
676
|
+
arrayContent = content;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (plainText !== null) {
|
|
681
|
+
const trimmed = plainText.trim();
|
|
682
|
+
if (trimmed) {
|
|
683
|
+
const header = msg.type === "user" ? "### User" : "### Assistant";
|
|
684
|
+
sections.push(`${header}\n\n${trimmed}`);
|
|
685
|
+
}
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (arrayContent === null) continue;
|
|
690
|
+
const content = arrayContent;
|
|
691
|
+
|
|
692
|
+
if (msg.type === "assistant") {
|
|
693
|
+
// Group all blocks from a single assistant message under one header
|
|
694
|
+
// so text and tool calls read as one coherent turn.
|
|
695
|
+
const parts: string[] = [];
|
|
696
|
+
for (const block of content) {
|
|
697
|
+
if (!block || typeof block !== "object") continue;
|
|
698
|
+
const b = block as Record<string, unknown>;
|
|
699
|
+
if (b["type"] === "text" && typeof b["text"] === "string") {
|
|
700
|
+
const txt = (b["text"] as string).trim();
|
|
701
|
+
if (txt) parts.push(txt);
|
|
702
|
+
} else if (b["type"] === "tool_use") {
|
|
703
|
+
const name =
|
|
704
|
+
typeof b["name"] === "string" ? (b["name"] as string) : "tool";
|
|
705
|
+
const input = renderToolInput(b["input"]);
|
|
706
|
+
parts.push(`**→ \`${name}\`**\n\n\`\`\`json\n${input}\n\`\`\``);
|
|
600
707
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
708
|
+
// Skip "thinking" blocks.
|
|
709
|
+
}
|
|
710
|
+
if (parts.length > 0) {
|
|
711
|
+
sections.push(`### Assistant\n\n${parts.join("\n\n")}`);
|
|
712
|
+
}
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// msg.type === "user" with array content — usually a batch of tool_results
|
|
717
|
+
// responding to the previous assistant turn's tool_use blocks. We skip
|
|
718
|
+
// the tool_result payloads entirely (see function docstring for why) and
|
|
719
|
+
// only surface any inline `text` blocks, which is where a real follow-up
|
|
720
|
+
// user turn would land.
|
|
721
|
+
for (const block of content) {
|
|
722
|
+
if (!block || typeof block !== "object") continue;
|
|
723
|
+
const b = block as Record<string, unknown>;
|
|
724
|
+
if (b["type"] === "text" && typeof b["text"] === "string") {
|
|
725
|
+
const txt = (b["text"] as string).trim();
|
|
726
|
+
if (txt) sections.push(`### User\n\n${txt}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return sections.join("\n\n");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Render a Copilot transcript as readable Markdown.
|
|
736
|
+
*
|
|
737
|
+
* Preserves the existing `assistant.message → content` extraction and adds
|
|
738
|
+
* `user.message` rendering plus any `toolCalls` attached to an assistant
|
|
739
|
+
* message. All other event types (`session.start`, `session.idle`, plain
|
|
740
|
+
* telemetry, etc.) are skipped.
|
|
741
|
+
*/
|
|
742
|
+
function renderCopilotTranscript(
|
|
743
|
+
events: ReadonlyArray<{ type?: unknown; data?: unknown }>,
|
|
744
|
+
): string {
|
|
745
|
+
const sections: string[] = [];
|
|
746
|
+
|
|
747
|
+
for (const evt of events) {
|
|
748
|
+
if (evt.type === "assistant.message") {
|
|
749
|
+
const data = evt.data;
|
|
750
|
+
if (!hasContent(data)) continue;
|
|
751
|
+
const parts: string[] = [];
|
|
752
|
+
const text = data.content.trim();
|
|
753
|
+
if (text) parts.push(text);
|
|
754
|
+
|
|
755
|
+
// toolCalls is an array on `assistant.message` data when present.
|
|
756
|
+
const toolCalls = (data as Record<string, unknown>)["toolCalls"];
|
|
757
|
+
if (Array.isArray(toolCalls)) {
|
|
758
|
+
for (const call of toolCalls) {
|
|
759
|
+
if (!call || typeof call !== "object") continue;
|
|
760
|
+
const c = call as Record<string, unknown>;
|
|
761
|
+
const name =
|
|
762
|
+
typeof c["name"] === "string"
|
|
763
|
+
? (c["name"] as string)
|
|
764
|
+
: typeof c["toolName"] === "string"
|
|
765
|
+
? (c["toolName"] as string)
|
|
766
|
+
: "tool";
|
|
767
|
+
const args = c["arguments"] ?? c["input"] ?? c["parameters"];
|
|
768
|
+
parts.push(
|
|
769
|
+
`**→ \`${name}\`**\n\n\`\`\`json\n${renderToolInput(args)}\n\`\`\``,
|
|
770
|
+
);
|
|
627
771
|
}
|
|
628
772
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
773
|
+
|
|
774
|
+
if (parts.length > 0) {
|
|
775
|
+
sections.push(`### Assistant\n\n${parts.join("\n\n")}`);
|
|
776
|
+
}
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (evt.type === "user.message") {
|
|
781
|
+
const data = evt.data;
|
|
782
|
+
if (hasContent(data)) {
|
|
783
|
+
const text = data.content.trim();
|
|
784
|
+
if (text) sections.push(`### User\n\n${text}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
// All other event types are intentionally skipped.
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return sections.join("\n\n");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Render an OpenCode prompt response as readable Markdown.
|
|
795
|
+
*
|
|
796
|
+
* OpenCode hands us `{ info, parts }`; `parts` is a discriminated union where
|
|
797
|
+
* `text` parts carry the assistant reply and `tool` parts carry tool
|
|
798
|
+
* invocations. `reasoning` and `subtask` parts are internal and omitted.
|
|
799
|
+
*/
|
|
800
|
+
function renderOpencodeTranscript(response: {
|
|
801
|
+
parts?: ReadonlyArray<{ type?: unknown; text?: unknown } & Record<string, unknown>>;
|
|
802
|
+
}): string {
|
|
803
|
+
if (!response.parts) return "";
|
|
804
|
+
const parts: string[] = [];
|
|
805
|
+
for (const part of response.parts) {
|
|
806
|
+
if (!part || typeof part !== "object") continue;
|
|
807
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
808
|
+
const txt = part.text.trim();
|
|
809
|
+
if (txt) parts.push(txt);
|
|
810
|
+
} else if (part.type === "tool") {
|
|
811
|
+
const name =
|
|
812
|
+
typeof part["tool"] === "string"
|
|
813
|
+
? (part["tool"] as string)
|
|
814
|
+
: typeof part["name"] === "string"
|
|
815
|
+
? (part["name"] as string)
|
|
816
|
+
: "tool";
|
|
817
|
+
const state = part["state"];
|
|
818
|
+
const args =
|
|
819
|
+
state && typeof state === "object"
|
|
820
|
+
? (state as Record<string, unknown>)["input"] ??
|
|
821
|
+
(state as Record<string, unknown>)["args"]
|
|
822
|
+
: undefined;
|
|
823
|
+
parts.push(
|
|
824
|
+
`**→ \`${name}\`**\n\n\`\`\`json\n${renderToolInput(args)}\n\`\`\``,
|
|
825
|
+
);
|
|
826
|
+
// Tool outputs are intentionally omitted — see the comment on
|
|
827
|
+
// `TRANSCRIPT_TOOL_INPUT_BUDGET` for the context-rot rationale.
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (parts.length === 0) return "";
|
|
831
|
+
return `### Assistant\n\n${parts.join("\n\n")}`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export function renderMessagesToText(messages: SavedMessage[]): string {
|
|
835
|
+
// Claude messages already come in as a flat chronological list — render
|
|
836
|
+
// the whole slice at once so the helper can cross-reference tool_use_ids
|
|
837
|
+
// against tool_result blocks. Copilot and OpenCode keep their existing
|
|
838
|
+
// per-message rendering.
|
|
839
|
+
const sections: string[] = [];
|
|
840
|
+
const claudeBatch: Array<{ type: string; message: unknown }> = [];
|
|
841
|
+
|
|
842
|
+
const flushClaude = (): void => {
|
|
843
|
+
if (claudeBatch.length === 0) return;
|
|
844
|
+
const rendered = renderClaudeTranscript(claudeBatch);
|
|
845
|
+
if (rendered) sections.push(rendered);
|
|
846
|
+
claudeBatch.length = 0;
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
for (const m of messages) {
|
|
850
|
+
if (m.provider === "claude") {
|
|
851
|
+
claudeBatch.push(
|
|
852
|
+
m.data as unknown as { type: string; message: unknown },
|
|
853
|
+
);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
flushClaude();
|
|
857
|
+
if (m.provider === "copilot") {
|
|
858
|
+
const rendered = renderCopilotTranscript([
|
|
859
|
+
m.data as unknown as { type?: unknown; data?: unknown },
|
|
860
|
+
]);
|
|
861
|
+
if (rendered) sections.push(rendered);
|
|
862
|
+
} else if (m.provider === "opencode") {
|
|
863
|
+
const rendered = renderOpencodeTranscript(
|
|
864
|
+
m.data as unknown as {
|
|
865
|
+
parts?: ReadonlyArray<
|
|
866
|
+
{ type?: unknown; text?: unknown } & Record<string, unknown>
|
|
867
|
+
>;
|
|
868
|
+
},
|
|
869
|
+
);
|
|
870
|
+
if (rendered) sections.push(rendered);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
flushClaude();
|
|
874
|
+
|
|
875
|
+
return sections.join("\n\n");
|
|
632
876
|
}
|
|
633
877
|
|
|
634
878
|
/** Resolve a SessionRef (string or SessionHandle) to the session name. */
|
|
@@ -946,8 +1190,6 @@ async function initProviderClientAndSession<A extends AgentType>(
|
|
|
946
1190
|
agent: A,
|
|
947
1191
|
serverUrl: string,
|
|
948
1192
|
paneId: string,
|
|
949
|
-
sessionId: string,
|
|
950
|
-
sessionDir: string,
|
|
951
1193
|
clientOpts: StageClientOptions<A>,
|
|
952
1194
|
sessionOpts: StageSessionOptions<A>,
|
|
953
1195
|
headless = false,
|
|
@@ -1009,16 +1251,26 @@ async function initProviderClientAndSession<A extends AgentType>(
|
|
|
1009
1251
|
case "claude": {
|
|
1010
1252
|
if (headless) {
|
|
1011
1253
|
// Headless Claude stages use the Agent SDK directly — no tmux pane.
|
|
1254
|
+
// Each query gets its own SDK-assigned session_id; the wrapper
|
|
1255
|
+
// tracks the latest one and exposes it as `sessionId`.
|
|
1012
1256
|
const client = new HeadlessClaudeClientWrapper();
|
|
1013
1257
|
await client.start();
|
|
1014
|
-
const session = new HeadlessClaudeSessionWrapper(
|
|
1015
|
-
|
|
1258
|
+
const session = new HeadlessClaudeSessionWrapper();
|
|
1259
|
+
// Cast through `unknown` — `HeadlessClaudeClientWrapper` intentionally
|
|
1260
|
+
// omits the interactive-only fields (`paneId`, `sessionDir`, etc.)
|
|
1261
|
+
// that `ClaudeClientWrapper` has; both satisfy the same runtime
|
|
1262
|
+
// contract used by workflow code.
|
|
1263
|
+
return { client, session } as unknown as Result;
|
|
1016
1264
|
}
|
|
1017
1265
|
const claudeClientOpts = clientOpts as StageClientOptions<"claude">;
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1266
|
+
const client = new ClaudeClientWrapper(paneId, claudeClientOpts);
|
|
1267
|
+
// `start()` now returns the Claude session UUID, which we pass through
|
|
1268
|
+
// to the session wrapper so `s.sessionId` is the Claude UUID (not the
|
|
1269
|
+
// atomic short ID). This fixes the parallel-workflow bug where save
|
|
1270
|
+
// used to look up "the newest Claude session globally" and could
|
|
1271
|
+
// attribute one branch's transcript to another.
|
|
1272
|
+
const claudeSessionId = await client.start();
|
|
1273
|
+
const session = new ClaudeSessionWrapper(paneId, claudeSessionId, onHIL);
|
|
1022
1274
|
return { client, session } as Result;
|
|
1023
1275
|
}
|
|
1024
1276
|
default:
|
|
@@ -1066,7 +1318,13 @@ async function cleanupProvider<A extends AgentType>(
|
|
|
1066
1318
|
case "claude":
|
|
1067
1319
|
// Headless Claude stages have no tmux pane to clear.
|
|
1068
1320
|
if (!paneId.startsWith("headless-")) {
|
|
1069
|
-
|
|
1321
|
+
try {
|
|
1322
|
+
await clearClaudeSession(paneId);
|
|
1323
|
+
} catch (e) {
|
|
1324
|
+
console.warn(
|
|
1325
|
+
`[cleanup] claude session clear failed: ${errorMessage(e)}`,
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1070
1328
|
}
|
|
1071
1329
|
break;
|
|
1072
1330
|
default:
|
|
@@ -1193,49 +1451,29 @@ function createSessionRunner(
|
|
|
1193
1451
|
const messagesPath = join(sessionDir, "messages.json");
|
|
1194
1452
|
const inboxPath = join(sessionDir, "inbox.md");
|
|
1195
1453
|
|
|
1196
|
-
// ── 11. Claude session snapshot (for identifying new sessions later) ──
|
|
1197
|
-
let knownClaudeSessionIds: Set<string> | undefined;
|
|
1198
|
-
if (shared.agent === "claude") {
|
|
1199
|
-
const { listSessions } = await import("@anthropic-ai/claude-agent-sdk");
|
|
1200
|
-
const existing = await listSessions({ dir: process.cwd() });
|
|
1201
|
-
knownClaudeSessionIds = new Set(existing.map((s) => s.sessionId));
|
|
1202
|
-
}
|
|
1203
|
-
const claudeSessionStartedAfter =
|
|
1204
|
-
shared.agent === "claude"
|
|
1205
|
-
? Date.now() - CLAUDE_SESSION_TIMESTAMP_BUFFER_MS
|
|
1206
|
-
: 0;
|
|
1207
|
-
|
|
1208
1454
|
// ── Message wrapping (Claude/Copilot/OpenCode) ──
|
|
1209
1455
|
async function wrapMessages(
|
|
1210
1456
|
arg: SessionEvent[] | SessionPromptResponse | string,
|
|
1211
1457
|
): Promise<SavedMessage[]> {
|
|
1212
1458
|
if (typeof arg === "string") {
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
: sessions.filter(
|
|
1221
|
-
(s) => s.lastModified >= claudeSessionStartedAfter,
|
|
1222
|
-
);
|
|
1223
|
-
|
|
1224
|
-
const candidates = newSessions.sort(
|
|
1225
|
-
(a, b) => b.lastModified - a.lastModified,
|
|
1226
|
-
);
|
|
1227
|
-
|
|
1228
|
-
const candidate = candidates[0];
|
|
1229
|
-
if (!candidate) {
|
|
1459
|
+
// `arg` is the Claude session UUID — either `s.sessionId` from an
|
|
1460
|
+
// interactive `ClaudeSessionWrapper` (set at `createClaudeSession`
|
|
1461
|
+
// time) or the SDK-emitted `session_id` tracked inside
|
|
1462
|
+
// `HeadlessClaudeSessionWrapper.query`. Using it directly removes
|
|
1463
|
+
// the "pick the globally newest Claude session" heuristic that
|
|
1464
|
+
// misattributed transcripts across parallel branches.
|
|
1465
|
+
if (!arg) {
|
|
1230
1466
|
throw new Error(
|
|
1231
|
-
|
|
1467
|
+
"wrapMessages: empty Claude session id. Call s.save(s.sessionId) " +
|
|
1468
|
+
"only after a successful s.session.query() (headless wrappers " +
|
|
1469
|
+
"only know their session_id once a query completes).",
|
|
1232
1470
|
);
|
|
1233
1471
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
);
|
|
1472
|
+
const { getSessionMessages } =
|
|
1473
|
+
await import("@anthropic-ai/claude-agent-sdk");
|
|
1474
|
+
const msgs: SessionMessage[] = await getSessionMessages(arg, {
|
|
1475
|
+
dir: process.cwd(),
|
|
1476
|
+
});
|
|
1239
1477
|
return msgs.map((m) => ({ provider: "claude" as const, data: m }));
|
|
1240
1478
|
}
|
|
1241
1479
|
|
|
@@ -1298,8 +1536,6 @@ function createSessionRunner(
|
|
|
1298
1536
|
shared.agent,
|
|
1299
1537
|
serverUrl,
|
|
1300
1538
|
paneId,
|
|
1301
|
-
sessionId,
|
|
1302
|
-
sessionDir,
|
|
1303
1539
|
clientOpts,
|
|
1304
1540
|
sessionOpts,
|
|
1305
1541
|
isHeadless,
|
|
@@ -1380,6 +1616,16 @@ function createSessionRunner(
|
|
|
1380
1616
|
// structured workflows read their declared fields the same way.
|
|
1381
1617
|
// A single uniform access pattern means workflow code never has
|
|
1382
1618
|
// to branch on "is this workflow structured or free-form".
|
|
1619
|
+
//
|
|
1620
|
+
// `s.sessionId` is the provider-specific session identifier — the
|
|
1621
|
+
// Claude session UUID, the Copilot session id, or the OpenCode
|
|
1622
|
+
// session id. This is what workflows pass to `s.save(s.sessionId)`
|
|
1623
|
+
// to disambiguate their own transcript when several sessions run
|
|
1624
|
+
// in parallel under the same workflow.
|
|
1625
|
+
const providerSessionId = resolveProviderSessionId(
|
|
1626
|
+
shared.agent,
|
|
1627
|
+
providerSession,
|
|
1628
|
+
);
|
|
1383
1629
|
const ctx: SessionContext = {
|
|
1384
1630
|
client: providerClient,
|
|
1385
1631
|
session: providerSession,
|
|
@@ -1387,7 +1633,7 @@ function createSessionRunner(
|
|
|
1387
1633
|
agent: shared.agent,
|
|
1388
1634
|
sessionDir,
|
|
1389
1635
|
paneId,
|
|
1390
|
-
sessionId,
|
|
1636
|
+
sessionId: providerSessionId,
|
|
1391
1637
|
save,
|
|
1392
1638
|
transcript: transcriptFn,
|
|
1393
1639
|
getMessages: getMessagesFn,
|
package/src/sdk/types.ts
CHANGED
|
@@ -22,7 +22,6 @@ import type {
|
|
|
22
22
|
import type {
|
|
23
23
|
ClaudeClientWrapper,
|
|
24
24
|
ClaudeSessionWrapper,
|
|
25
|
-
ClaudeQueryDefaults,
|
|
26
25
|
} from "./providers/claude.ts";
|
|
27
26
|
|
|
28
27
|
/** Supported agent types */
|
|
@@ -44,7 +43,7 @@ type ClientOptionsMap = {
|
|
|
44
43
|
* Maps each agent to the session create options the user passes to `ctx.stage()`.
|
|
45
44
|
* - OpenCode: `client.session.create()` body params
|
|
46
45
|
* - Copilot: `client.createSession()` config (onPermissionRequest defaults to approveAll)
|
|
47
|
-
* - Claude:
|
|
46
|
+
* - Claude: no per-session options — delivery is driven entirely by Stop hooks.
|
|
48
47
|
*/
|
|
49
48
|
type SessionOptionsMap = {
|
|
50
49
|
opencode: {
|
|
@@ -53,7 +52,7 @@ type SessionOptionsMap = {
|
|
|
53
52
|
workspaceID?: string;
|
|
54
53
|
};
|
|
55
54
|
copilot: Partial<CopilotSessionConfig>;
|
|
56
|
-
claude:
|
|
55
|
+
claude: Record<string, never>;
|
|
57
56
|
};
|
|
58
57
|
|
|
59
58
|
/** Maps each agent to the `s.client` type provided in the stage callback. */
|
|
@@ -92,7 +91,6 @@ export type {
|
|
|
92
91
|
OpencodeSession,
|
|
93
92
|
ClaudeClientWrapper,
|
|
94
93
|
ClaudeSessionWrapper,
|
|
95
|
-
ClaudeQueryDefaults,
|
|
96
94
|
};
|
|
97
95
|
|
|
98
96
|
// ─── Validation ─────────────────────────────────────────────────────────────
|