anyclaude-sdk 0.1.0 → 0.3.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.
package/dist/agent.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import type { AgentDefinition, CanUseTool, CommandExecutor, FileSystem, HookCallback, HookEvent, PermissionMode, SDKMessage, SDKUserMessage } from './types/index.js';
1
+ import type { AgentDefinition, CanUseTool, CommandExecutor, ContentBlockParam, FileSystem, HookCallback, HookEvent, PermissionMode, SDKMessage, SDKUserMessage } from './types/index.js';
2
2
  import type { FileReadLimits, Tool } from './tools/types.js';
3
3
  import { type McpServers, type McpProxy } from './mcp/index.js';
4
4
  import type { SlashCommand } from './commands/index.js';
5
5
  import { BackgroundTaskManager } from './background/index.js';
6
6
  import { Mailbox, TaskBoard } from './team/index.js';
7
7
  import type { MemoryStore } from './memory/index.js';
8
- import type { SessionStore } from './session/index.js';
8
+ import type { SessionStoreLike } from './session/index.js';
9
9
  import { type Settings } from './settings/index.js';
10
10
  import { type Skill } from './skills/index.js';
11
11
  export type Workspace = FileSystem & CommandExecutor;
@@ -27,6 +27,23 @@ export interface AgentOptions {
27
27
  /** Denylist of tool names, applied after allowedTools. */
28
28
  disallowedTools?: string[];
29
29
  maxTurns?: number;
30
+ /** Wall-clock budget (ms). At a turn boundary past this, the loop pauses: it
31
+ * persists to sessionStore and emits a `paused` system message instead of
32
+ * continuing — for spanning serverless function time limits ("survivor"). */
33
+ maxDurationMs?: number;
34
+ /** Resume + CONTINUE the tool loop on the stored transcript without a new user
35
+ * message (pairs with `resume`). Used to continue after a `paused` boundary. */
36
+ continueRun?: boolean;
37
+ /** Tool names executed by the HOST/client, not the server. When the agent calls
38
+ * one, the loop emits a `client_tool_request` + pauses; the client runs it and
39
+ * resumes (continueRun) with `clientToolResults`. (e.g. bash on a browser WebContainer.) */
40
+ clientTools?: string[];
41
+ /** Results for client-tool calls, injected into the transcript before continuing. */
42
+ clientToolResults?: Array<{
43
+ tool_use_id: string;
44
+ content: string | ContentBlockParam[];
45
+ is_error?: boolean;
46
+ }>;
30
47
  cwd?: string;
31
48
  sessionId?: string;
32
49
  abortController?: AbortController;
@@ -76,7 +93,7 @@ export interface AgentOptions {
76
93
  /** This agent's name/label for messaging (default 'coordinator'). */
77
94
  agentName?: string;
78
95
  /** Persist the transcript to this store (keyed by sessionId) for resume. */
79
- sessionStore?: SessionStore;
96
+ sessionStore?: SessionStoreLike;
80
97
  /** Load the stored transcript for sessionId before the first turn. */
81
98
  resume?: boolean;
82
99
  /** Auto-compact the transcript when it approaches the context limit. */
@@ -95,6 +112,16 @@ export interface AgentOptions {
95
112
  };
96
113
  /** Prompt callback for 'ask' decisions; if absent, default mode allows / dontAsk denies. */
97
114
  onPermissionAsk?: (toolName: string, input: Record<string, unknown>) => Promise<boolean>;
115
+ /** Surfaces `ask_user_question` to the host UI. When set, the tool is registered. */
116
+ onAskUser?: (q: {
117
+ question: string;
118
+ header?: string;
119
+ options: Array<{
120
+ label: string;
121
+ description?: string;
122
+ }>;
123
+ multiSelect?: boolean;
124
+ }) => Promise<string | string[]>;
98
125
  /** Load + apply `.claude/settings.json` (project/local cascade) under explicit options. true, or a Settings object. */
99
126
  settings?: boolean | Settings;
100
127
  /** Load `.claude/skills/*.md` as slash commands + skill registry. true, or a Skill[] array. */
package/dist/agent.js CHANGED
@@ -10,6 +10,7 @@
10
10
  // 7. Repeat until no tool calls or max turns reached
11
11
  import { ALL_CLAUDE_CODE_TOOLS, toolByName, toolDefs } from './tools/index.js';
12
12
  import { task as taskTool } from './tools/task.js';
13
+ import { askUserQuestion } from './tools/ask_user.js';
13
14
  import { loadMcpServers } from './mcp/index.js';
14
15
  import { runSlashCommand } from './commands/index.js';
15
16
  import { BackgroundTaskManager, BACKGROUND_TOOLS } from './background/index.js';
@@ -201,6 +202,7 @@ export async function* runAgent(options) {
201
202
  ? options.backgroundManager ?? new BackgroundTaskManager()
202
203
  : undefined;
203
204
  const messageQueue = options.messageQueue;
205
+ const clientTools = new Set(options.clientTools ?? []);
204
206
  // Teammates: a shared Mailbox + TaskBoard (reused from the parent when this
205
207
  // is a sub-agent) + team tools + coordinator prompt.
206
208
  const teamEnabled = options.team === true;
@@ -214,6 +216,9 @@ export async function* runAgent(options) {
214
216
  const present = new Set(localTools.map((t) => t.def.function.name));
215
217
  localTools = [...localTools, ...BACKGROUND_TOOLS.filter((t) => !present.has(t.def.function.name))];
216
218
  }
219
+ if (options.onAskUser && !localTools.some((t) => t.def.function.name === 'ask_user_question')) {
220
+ localTools = [...localTools, askUserQuestion];
221
+ }
217
222
  if (teamEnabled) {
218
223
  const present = new Set(localTools.map((t) => t.def.function.name));
219
224
  const teamSet = subagentsEnabled ? [...TEAM_TOOLS, ...TEAM_DISPATCH_TOOLS] : TEAM_TOOLS;
@@ -267,6 +272,7 @@ export async function* runAgent(options) {
267
272
  signal,
268
273
  store,
269
274
  limits,
275
+ askUser: options.onAskUser,
270
276
  background,
271
277
  mailbox,
272
278
  board,
@@ -308,6 +314,7 @@ export async function* runAgent(options) {
308
314
  tools: subTools,
309
315
  model: def?.model ?? model,
310
316
  systemPrompt: subSystem,
317
+ onAskUser: options.onAskUser,
311
318
  maxTurns,
312
319
  cwd,
313
320
  abortController: childController,
@@ -403,6 +410,10 @@ export async function* runAgent(options) {
403
410
  });
404
411
  const startedAt = Date.now();
405
412
  const sessionUsage = emptyUsage();
413
+ const maxDurationMs = options.maxDurationMs;
414
+ let paused = false;
415
+ // Pending client-executed tool calls for the current turn (emitted on pause).
416
+ let clientRequests = [];
406
417
  // Resume: seed the transcript from a prior session before the first turn.
407
418
  if (options.resume && options.sessionStore) {
408
419
  const prior = await options.sessionStore.load(sessionId);
@@ -412,12 +423,16 @@ export async function* runAgent(options) {
412
423
  history.splice(0, history.length, ...prior);
413
424
  }
414
425
  }
415
- for await (const userMsg of prompt) {
426
+ // continueRun: prepend a sentinel turn so the loop continues the stored
427
+ // transcript with no new user message (used after a `paused` boundary).
428
+ const promptSrc = options.continueRun ? withContinueSentinel(prompt) : prompt;
429
+ for await (const userMsg of promptSrc) {
416
430
  if (signal?.aborted)
417
431
  break;
432
+ const isContinue = userMsg.__continue === true;
418
433
  const content = userMsg.message.content;
419
434
  // Slash-command interception: a string user turn beginning with '/'.
420
- if (typeof content === 'string' && content.trim().startsWith('/')) {
435
+ if (!isContinue && typeof content === 'string' && content.trim().startsWith('/')) {
421
436
  const outcome = await runSlashCommand(content, {
422
437
  history,
423
438
  tools: defs.map((d) => ({
@@ -479,7 +494,7 @@ export async function* runAgent(options) {
479
494
  history.push({ role: 'user', content }); // unknown command → normal prompt
480
495
  }
481
496
  }
482
- else {
497
+ else if (!isContinue) {
483
498
  history.push({ role: 'user', content });
484
499
  const pre = await runHooks('UserPromptSubmit', {
485
500
  hook_event_name: 'UserPromptSubmit',
@@ -489,6 +504,28 @@ export async function* runAgent(options) {
489
504
  if (extra)
490
505
  history.push({ role: 'user', content: extra });
491
506
  }
507
+ // Continue after a client-tool pause: inject the host-executed results into
508
+ // the transcript so the (paused) assistant tool_use calls are now resolved.
509
+ if (isContinue && options.clientToolResults?.length) {
510
+ const blocks = [];
511
+ for (const r of options.clientToolResults) {
512
+ history.push({
513
+ role: 'tool',
514
+ tool_call_id: r.tool_use_id,
515
+ content: typeof r.content === 'string' ? r.content : JSON.stringify(r.content),
516
+ });
517
+ blocks.push({ type: 'tool_result', tool_use_id: r.tool_use_id, content: r.content, is_error: r.is_error || undefined });
518
+ }
519
+ yield {
520
+ type: 'user',
521
+ message: { role: 'user', content: blocks },
522
+ parent_tool_use_id: null,
523
+ isSynthetic: true,
524
+ timestamp: new Date().toISOString(),
525
+ uuid: uuid(),
526
+ session_id: sessionId,
527
+ };
528
+ }
492
529
  let turns = 0;
493
530
  let lastText = '';
494
531
  let resultModel = model ?? 'unknown';
@@ -505,6 +542,11 @@ export async function* runAgent(options) {
505
542
  hitMaxTurns = true;
506
543
  break;
507
544
  }
545
+ // Survivor: pause at this turn boundary if we're past the time budget.
546
+ if (maxDurationMs != null && Date.now() - startedAt >= maxDurationMs) {
547
+ paused = true;
548
+ break;
549
+ }
508
550
  turns++;
509
551
  // Message queue: deliver one interjected user message per turn boundary.
510
552
  // (Messages enqueued via options.messageQueue while this loop runs.)
@@ -648,12 +690,19 @@ export async function* runAgent(options) {
648
690
  }
649
691
  // Execute tool calls (permission gate + hooks around each).
650
692
  const toolResultBlocks = [];
693
+ clientRequests = [];
651
694
  const turnMedia = [];
652
695
  for (const call of calls) {
653
696
  if (signal?.aborted)
654
697
  break;
655
698
  const name = call.function.name;
656
699
  let input = safeParse(call.function.arguments);
700
+ // Client-executed tool: don't run it here — record it; we pause after this
701
+ // turn's server tools and let the host execute + resume with the result.
702
+ if (clientTools.has(name)) {
703
+ clientRequests.push({ tool_use_id: call.id, name, input });
704
+ continue;
705
+ }
657
706
  const tool = byName.get(name);
658
707
  let content = '';
659
708
  let isError = false;
@@ -818,15 +867,23 @@ export async function* runAgent(options) {
818
867
  ],
819
868
  });
820
869
  }
821
- yield {
822
- type: 'user',
823
- message: { role: 'user', content: toolResultBlocks },
824
- parent_tool_use_id: null,
825
- isSynthetic: true,
826
- timestamp: new Date().toISOString(),
827
- uuid: uuid(),
828
- session_id: sessionId,
829
- };
870
+ if (toolResultBlocks.length) {
871
+ yield {
872
+ type: 'user',
873
+ message: { role: 'user', content: toolResultBlocks },
874
+ parent_tool_use_id: null,
875
+ isSynthetic: true,
876
+ timestamp: new Date().toISOString(),
877
+ uuid: uuid(),
878
+ session_id: sessionId,
879
+ };
880
+ }
881
+ // Client-executed tools were requested this turn → pause; the host runs
882
+ // them and resumes (continueRun + clientToolResults).
883
+ if (clientRequests.length) {
884
+ paused = true;
885
+ break;
886
+ }
830
887
  }
831
888
  await runHooks('Stop', {
832
889
  hook_event_name: 'Stop',
@@ -891,7 +948,39 @@ export async function* runAgent(options) {
891
948
  /* persistence is best-effort */
892
949
  }
893
950
  }
951
+ // Survivor / client-tools: paused at a boundary. Transcript persisted above.
952
+ // Emit any client-tool requests for the host to execute, then signal the
953
+ // client to continue in a fresh invocation (resume + continueRun [+ results]).
954
+ if (paused) {
955
+ for (const req of clientRequests) {
956
+ yield {
957
+ type: 'system',
958
+ subtype: 'client_tool_request',
959
+ request: req,
960
+ session_id: sessionId,
961
+ uuid: uuid(),
962
+ };
963
+ }
964
+ yield {
965
+ type: 'system',
966
+ subtype: 'paused',
967
+ reason: clientRequests.length ? 'client_tool' : 'time_budget',
968
+ session_id: sessionId,
969
+ uuid: uuid(),
970
+ };
971
+ break;
972
+ }
894
973
  }
895
974
  // The prompt stream is exhausted — the session is ending.
896
975
  await runHooks('SessionEnd', { hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' });
897
976
  }
977
+ /** Prepend a continue-sentinel turn so the loop continues a resumed transcript. */
978
+ async function* withContinueSentinel(prompt) {
979
+ yield {
980
+ type: 'user',
981
+ message: { role: 'user', content: '' },
982
+ parent_tool_use_id: null,
983
+ __continue: true,
984
+ };
985
+ yield* prompt;
986
+ }
@@ -123,7 +123,7 @@ const sessions = {
123
123
  name: 'sessions',
124
124
  description: 'List saved sessions',
125
125
  async run(_args, ctx) {
126
- if (!ctx.sessionStore)
126
+ if (!ctx.sessionStore?.list)
127
127
  return { systemText: 'No session store configured.' };
128
128
  const list = await ctx.sessionStore.list();
129
129
  if (!list.length)
@@ -153,7 +153,7 @@ const rename = {
153
153
  description: 'Rename the current session',
154
154
  argumentHint: '<title>',
155
155
  async run(args, ctx) {
156
- if (!ctx.sessionStore || !ctx.sessionId)
156
+ if (!ctx.sessionStore?.rename || !ctx.sessionId)
157
157
  return { systemText: 'No active session to rename.' };
158
158
  const title = args.trim();
159
159
  if (!title)
@@ -36,17 +36,7 @@ export interface SlashCommandContext {
36
36
  /** Current session id. */
37
37
  sessionId?: string;
38
38
  /** Session store, for /sessions, /resume, /rename. */
39
- sessionStore?: {
40
- list(): Promise<Array<{
41
- sessionId: string;
42
- title?: string;
43
- updatedAt: number;
44
- messageCount: number;
45
- }>>;
46
- load(id: string): Promise<ChatMsg[] | null>;
47
- rename(id: string, title: string): Promise<void>;
48
- remove(id: string): Promise<void>;
49
- };
39
+ sessionStore?: import('../session/types.js').SessionStoreLike;
50
40
  /** Paths the model has read this session, for /files. */
51
41
  readFiles?: Set<string>;
52
42
  /** Configured sub-agents, for /agents. */
package/dist/query.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { AgentDefinition, CanUseTool, HookCallback, HookEvent, LLMClient, P
2
2
  import type { FileReadLimits, Tool } from './tools/types.js';
3
3
  import type { McpServers, McpProxy } from './mcp/index.js';
4
4
  import type { SlashCommand } from './commands/index.js';
5
- import type { SessionStore } from './session/index.js';
5
+ import type { SessionStoreLike } from './session/index.js';
6
6
  import type { MemoryStore } from './memory/index.js';
7
7
  import { type Workspace } from './agent.js';
8
8
  export interface QueryOptions {
@@ -22,6 +22,19 @@ export interface QueryOptions {
22
22
  allowedTools?: string[];
23
23
  disallowedTools?: string[];
24
24
  maxTurns?: number;
25
+ /** Wall-clock budget (ms): pause at a turn boundary past this + emit `paused` (survivor). */
26
+ maxDurationMs?: number;
27
+ /** Resume + continue the tool loop with no new user message (after a `paused` boundary). */
28
+ continueRun?: boolean;
29
+ /** Tool names the HOST/client executes (e.g. bash on a browser WebContainer). The agent
30
+ * emits a `client_tool_request` + pauses; the client runs it and resumes with results. */
31
+ clientTools?: string[];
32
+ /** Results for client-tool calls, injected before continuing (with continueRun). */
33
+ clientToolResults?: Array<{
34
+ tool_use_id: string;
35
+ content: string | import('./types/index.js').ContentBlockParam[];
36
+ is_error?: boolean;
37
+ }>;
25
38
  cwd?: string;
26
39
  sessionId?: string;
27
40
  abortController?: AbortController;
@@ -59,7 +72,7 @@ export interface QueryOptions {
59
72
  /** This agent's name/label for messaging (default 'coordinator'). */
60
73
  agentName?: string;
61
74
  /** Persist the transcript to this store (keyed by sessionId) for resume. */
62
- sessionStore?: SessionStore;
75
+ sessionStore?: SessionStoreLike;
63
76
  /** Load the stored transcript for sessionId before the first turn. */
64
77
  resume?: boolean;
65
78
  /** Auto-compact the transcript when it nears the context limit. */
@@ -78,6 +91,16 @@ export interface QueryOptions {
78
91
  };
79
92
  /** Prompt callback for 'ask' permission decisions. */
80
93
  onPermissionAsk?: (toolName: string, input: Record<string, unknown>) => Promise<boolean>;
94
+ /** Handler for the `ask_user_question` tool. When set, the tool is registered. */
95
+ onAskUser?: (q: {
96
+ question: string;
97
+ header?: string;
98
+ options: Array<{
99
+ label: string;
100
+ description?: string;
101
+ }>;
102
+ multiSelect?: boolean;
103
+ }) => Promise<string | string[]>;
81
104
  /** Load `.claude/settings.json` (project/local cascade), or pass a Settings object. */
82
105
  settings?: boolean | import('./settings/index.js').Settings;
83
106
  /** Load `.claude/skills/*.md` as slash commands + a skill registry, or pass a Skill[]. */
package/dist/query.js CHANGED
@@ -20,6 +20,10 @@ export function query(options) {
20
20
  allowedTools: options.allowedTools,
21
21
  disallowedTools: options.disallowedTools,
22
22
  maxTurns: options.maxTurns,
23
+ maxDurationMs: options.maxDurationMs,
24
+ continueRun: options.continueRun,
25
+ clientTools: options.clientTools,
26
+ clientToolResults: options.clientToolResults,
23
27
  cwd: options.cwd,
24
28
  sessionId: options.sessionId,
25
29
  abortController,
@@ -48,6 +52,7 @@ export function query(options) {
48
52
  memory: options.memory,
49
53
  permissionRules: options.permissionRules,
50
54
  onPermissionAsk: options.onPermissionAsk,
55
+ onAskUser: options.onAskUser,
51
56
  settings: options.settings,
52
57
  skills: options.skills,
53
58
  });
@@ -0,0 +1,25 @@
1
+ import type { ChatMsg } from '../../types/index.js';
2
+ import type { SessionMeta, SessionStoreLike, StoredSession } from '../types.js';
3
+ /** Structural view of a KV client (Vercel KV / Upstash Redis / Cloudflare KV). */
4
+ export interface KVClientLike {
5
+ get(key: string): Promise<unknown>;
6
+ set(key: string, value: string): Promise<unknown>;
7
+ del?(key: string): Promise<unknown>;
8
+ /** Glob-style key listing (e.g. Upstash/ioredis `keys('bcs:session:*')`). */
9
+ keys?(pattern: string): Promise<string[]>;
10
+ }
11
+ export declare class KVSessionStore implements SessionStoreLike {
12
+ private readonly kv;
13
+ private readonly prefix;
14
+ constructor(kv: KVClientLike, prefix?: string);
15
+ private key;
16
+ get(sessionId: string): Promise<StoredSession | null>;
17
+ load(sessionId: string): Promise<ChatMsg[] | null>;
18
+ save(sessionId: string, transcript: ChatMsg[], meta?: {
19
+ title?: string;
20
+ model?: string;
21
+ }): Promise<void>;
22
+ list(): Promise<SessionMeta[]>;
23
+ rename(sessionId: string, title: string): Promise<void>;
24
+ remove(sessionId: string): Promise<void>;
25
+ }
@@ -0,0 +1,55 @@
1
+ function parse(raw) {
2
+ if (raw == null)
3
+ return null;
4
+ const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
5
+ return obj && Array.isArray(obj.transcript) ? obj : null;
6
+ }
7
+ export class KVSessionStore {
8
+ constructor(kv, prefix = 'bcs:session:') {
9
+ this.kv = kv;
10
+ this.prefix = prefix;
11
+ }
12
+ key(id) {
13
+ return this.prefix + id;
14
+ }
15
+ async get(sessionId) {
16
+ return parse(await this.kv.get(this.key(sessionId)));
17
+ }
18
+ async load(sessionId) {
19
+ const s = await this.get(sessionId);
20
+ return s ? s.transcript : null;
21
+ }
22
+ async save(sessionId, transcript, meta = {}) {
23
+ const now = Date.now();
24
+ const existing = await this.get(sessionId);
25
+ const row = {
26
+ sessionId,
27
+ title: meta.title ?? existing?.title,
28
+ model: meta.model ?? existing?.model,
29
+ createdAt: existing?.createdAt ?? now,
30
+ updatedAt: now,
31
+ messageCount: transcript.length,
32
+ transcript,
33
+ };
34
+ await this.kv.set(this.key(sessionId), JSON.stringify(row));
35
+ }
36
+ async list() {
37
+ if (!this.kv.keys)
38
+ return [];
39
+ const keys = await this.kv.keys(this.prefix + '*');
40
+ const rows = await Promise.all(keys.map((k) => this.kv.get(k).then(parse)));
41
+ return rows
42
+ .filter((s) => !!s)
43
+ .map(({ transcript: _t, ...meta }) => meta)
44
+ .sort((a, b) => b.updatedAt - a.updatedAt);
45
+ }
46
+ async rename(sessionId, title) {
47
+ const s = await this.get(sessionId);
48
+ if (!s)
49
+ return;
50
+ await this.kv.set(this.key(sessionId), JSON.stringify({ ...s, title, updatedAt: Date.now() }));
51
+ }
52
+ async remove(sessionId) {
53
+ await this.kv.del?.(this.key(sessionId));
54
+ }
55
+ }
@@ -0,0 +1,14 @@
1
+ import type { ChatMsg } from '../../types/index.js';
2
+ import type { SessionMeta, SessionStoreLike, StoredSession } from '../types.js';
3
+ export declare class MemorySessionStore implements SessionStoreLike {
4
+ private readonly data;
5
+ load(sessionId: string): Promise<ChatMsg[] | null>;
6
+ save(sessionId: string, transcript: ChatMsg[], meta?: {
7
+ title?: string;
8
+ model?: string;
9
+ }): Promise<void>;
10
+ get(sessionId: string): Promise<StoredSession | null>;
11
+ list(): Promise<SessionMeta[]>;
12
+ rename(sessionId: string, title: string): Promise<void>;
13
+ remove(sessionId: string): Promise<void>;
14
+ }
@@ -0,0 +1,37 @@
1
+ export class MemorySessionStore {
2
+ constructor() {
3
+ this.data = new Map();
4
+ }
5
+ async load(sessionId) {
6
+ return this.data.get(sessionId)?.transcript ?? null;
7
+ }
8
+ async save(sessionId, transcript, meta) {
9
+ const now = Date.now();
10
+ const prev = this.data.get(sessionId);
11
+ this.data.set(sessionId, {
12
+ sessionId,
13
+ title: meta?.title ?? prev?.title,
14
+ model: meta?.model ?? prev?.model,
15
+ createdAt: prev?.createdAt ?? now,
16
+ updatedAt: now,
17
+ messageCount: transcript.length,
18
+ transcript: transcript.slice(),
19
+ });
20
+ }
21
+ async get(sessionId) {
22
+ return this.data.get(sessionId) ?? null;
23
+ }
24
+ async list() {
25
+ return [...this.data.values()]
26
+ .map(({ transcript: _t, ...meta }) => meta)
27
+ .sort((a, b) => b.updatedAt - a.updatedAt);
28
+ }
29
+ async rename(sessionId, title) {
30
+ const s = this.data.get(sessionId);
31
+ if (s)
32
+ s.title = title;
33
+ }
34
+ async remove(sessionId) {
35
+ this.data.delete(sessionId);
36
+ }
37
+ }
@@ -0,0 +1,25 @@
1
+ import type { ChatMsg } from '../../types/index.js';
2
+ import type { SessionMeta, SessionStoreLike, StoredSession } from '../types.js';
3
+ /** Structural Postgres query runner (node-postgres Pool/Client compatible). */
4
+ export interface PgRunnerLike {
5
+ query(text: string, params?: unknown[]): Promise<{
6
+ rows: Record<string, unknown>[];
7
+ }>;
8
+ }
9
+ export declare const POSTGRES_SCHEMA: string;
10
+ export declare class PostgresSessionStore implements SessionStoreLike {
11
+ private readonly pg;
12
+ private readonly table;
13
+ constructor(pg: PgRunnerLike, table?: string);
14
+ /** Create the table if it doesn't exist (optional convenience). */
15
+ migrate(): Promise<void>;
16
+ load(sessionId: string): Promise<ChatMsg[] | null>;
17
+ get(sessionId: string): Promise<StoredSession | null>;
18
+ save(sessionId: string, transcript: ChatMsg[], meta?: {
19
+ title?: string;
20
+ model?: string;
21
+ }): Promise<void>;
22
+ list(): Promise<SessionMeta[]>;
23
+ rename(sessionId: string, title: string): Promise<void>;
24
+ remove(sessionId: string): Promise<void>;
25
+ }
@@ -0,0 +1,72 @@
1
+ export const POSTGRES_SCHEMA = `
2
+ CREATE TABLE IF NOT EXISTS sessions (
3
+ id text PRIMARY KEY,
4
+ title text,
5
+ model text,
6
+ created_at bigint NOT NULL,
7
+ updated_at bigint NOT NULL,
8
+ message_count int NOT NULL DEFAULT 0,
9
+ transcript jsonb NOT NULL DEFAULT '[]'::jsonb
10
+ );
11
+ CREATE INDEX IF NOT EXISTS sessions_updated_at_idx ON sessions (updated_at DESC);
12
+ `.trim();
13
+ function toMeta(r) {
14
+ return {
15
+ sessionId: String(r.id),
16
+ title: r.title ?? undefined,
17
+ model: r.model ?? undefined,
18
+ createdAt: Number(r.created_at),
19
+ updatedAt: Number(r.updated_at),
20
+ messageCount: Number(r.message_count),
21
+ };
22
+ }
23
+ export class PostgresSessionStore {
24
+ constructor(pg, table = 'sessions') {
25
+ this.pg = pg;
26
+ this.table = table;
27
+ }
28
+ /** Create the table if it doesn't exist (optional convenience). */
29
+ async migrate() {
30
+ await this.pg.query(POSTGRES_SCHEMA);
31
+ }
32
+ async load(sessionId) {
33
+ const { rows } = await this.pg.query(`SELECT transcript FROM ${this.table} WHERE id = $1`, [sessionId]);
34
+ if (!rows.length)
35
+ return null;
36
+ const t = rows[0].transcript;
37
+ return (typeof t === 'string' ? JSON.parse(t) : t);
38
+ }
39
+ async get(sessionId) {
40
+ const { rows } = await this.pg.query(`SELECT * FROM ${this.table} WHERE id = $1`, [sessionId]);
41
+ if (!rows.length)
42
+ return null;
43
+ const r = rows[0];
44
+ const t = r.transcript;
45
+ return { ...toMeta(r), transcript: (typeof t === 'string' ? JSON.parse(t) : t) };
46
+ }
47
+ async save(sessionId, transcript, meta = {}) {
48
+ const now = Date.now();
49
+ await this.pg.query(`INSERT INTO ${this.table} (id, title, model, created_at, updated_at, message_count, transcript)
50
+ VALUES ($1, $2, $3, $4, $4, $5, $6::jsonb)
51
+ ON CONFLICT (id) DO UPDATE SET
52
+ title = COALESCE(EXCLUDED.title, ${this.table}.title),
53
+ model = COALESCE(EXCLUDED.model, ${this.table}.model),
54
+ updated_at = EXCLUDED.updated_at,
55
+ message_count = EXCLUDED.message_count,
56
+ transcript = EXCLUDED.transcript`, [sessionId, meta.title ?? null, meta.model ?? null, now, transcript.length, JSON.stringify(transcript)]);
57
+ }
58
+ async list() {
59
+ const { rows } = await this.pg.query(`SELECT id, title, model, created_at, updated_at, message_count FROM ${this.table} ORDER BY updated_at DESC`);
60
+ return rows.map(toMeta);
61
+ }
62
+ async rename(sessionId, title) {
63
+ await this.pg.query(`UPDATE ${this.table} SET title = $2, updated_at = $3 WHERE id = $1`, [
64
+ sessionId,
65
+ title,
66
+ Date.now(),
67
+ ]);
68
+ }
69
+ async remove(sessionId) {
70
+ await this.pg.query(`DELETE FROM ${this.table} WHERE id = $1`, [sessionId]);
71
+ }
72
+ }
@@ -0,0 +1,24 @@
1
+ import type { ChatMsg } from '../../types/index.js';
2
+ import type { SessionMeta, SessionStoreLike, StoredSession } from '../types.js';
3
+ /** Structural view of a Redis client (ioredis / node-redis). */
4
+ export interface RedisClientLike {
5
+ get(key: string): Promise<string | null>;
6
+ set(key: string, value: string): Promise<unknown>;
7
+ del(key: string): Promise<unknown>;
8
+ keys(pattern: string): Promise<string[]>;
9
+ }
10
+ export declare class RedisSessionStore implements SessionStoreLike {
11
+ private readonly redis;
12
+ private readonly prefix;
13
+ constructor(redis: RedisClientLike, prefix?: string);
14
+ private key;
15
+ get(sessionId: string): Promise<StoredSession | null>;
16
+ load(sessionId: string): Promise<ChatMsg[] | null>;
17
+ save(sessionId: string, transcript: ChatMsg[], meta?: {
18
+ title?: string;
19
+ model?: string;
20
+ }): Promise<void>;
21
+ list(): Promise<SessionMeta[]>;
22
+ rename(sessionId: string, title: string): Promise<void>;
23
+ remove(sessionId: string): Promise<void>;
24
+ }
@@ -0,0 +1,52 @@
1
+ export class RedisSessionStore {
2
+ constructor(redis, prefix = 'bcs:session:') {
3
+ this.redis = redis;
4
+ this.prefix = prefix;
5
+ }
6
+ key(id) {
7
+ return this.prefix + id;
8
+ }
9
+ async get(sessionId) {
10
+ const raw = await this.redis.get(this.key(sessionId));
11
+ if (!raw)
12
+ return null;
13
+ const obj = JSON.parse(raw);
14
+ return Array.isArray(obj.transcript) ? obj : null;
15
+ }
16
+ async load(sessionId) {
17
+ const s = await this.get(sessionId);
18
+ return s ? s.transcript : null;
19
+ }
20
+ async save(sessionId, transcript, meta = {}) {
21
+ const now = Date.now();
22
+ const existing = await this.get(sessionId);
23
+ const row = {
24
+ sessionId,
25
+ title: meta.title ?? existing?.title,
26
+ model: meta.model ?? existing?.model,
27
+ createdAt: existing?.createdAt ?? now,
28
+ updatedAt: now,
29
+ messageCount: transcript.length,
30
+ transcript,
31
+ };
32
+ await this.redis.set(this.key(sessionId), JSON.stringify(row));
33
+ }
34
+ async list() {
35
+ const keys = await this.redis.keys(this.prefix + '*');
36
+ const raws = await Promise.all(keys.map((k) => this.redis.get(k)));
37
+ return raws
38
+ .filter((r) => !!r)
39
+ .map((r) => JSON.parse(r))
40
+ .map(({ transcript: _t, ...meta }) => meta)
41
+ .sort((a, b) => b.updatedAt - a.updatedAt);
42
+ }
43
+ async rename(sessionId, title) {
44
+ const s = await this.get(sessionId);
45
+ if (!s)
46
+ return;
47
+ await this.redis.set(this.key(sessionId), JSON.stringify({ ...s, title, updatedAt: Date.now() }));
48
+ }
49
+ async remove(sessionId) {
50
+ await this.redis.del(this.key(sessionId));
51
+ }
52
+ }
@@ -0,0 +1,51 @@
1
+ import type { ChatMsg } from '../../types/index.js';
2
+ import type { SessionMeta, SessionStoreLike, StoredSession } from '../types.js';
3
+ export declare const SUPABASE_SCHEMA: string;
4
+ interface SessionRow {
5
+ id: string;
6
+ title: string | null;
7
+ model: string | null;
8
+ created_at: number;
9
+ updated_at: number;
10
+ message_count: number;
11
+ transcript: ChatMsg[];
12
+ }
13
+ interface SupaResp<T> {
14
+ data: T | null;
15
+ error: {
16
+ message: string;
17
+ } | null;
18
+ }
19
+ interface SupaQuery<T> extends PromiseLike<SupaResp<T[]>> {
20
+ select(columns?: string): SupaQuery<T>;
21
+ eq(column: string, value: unknown): SupaQuery<T>;
22
+ order(column: string, options?: {
23
+ ascending?: boolean;
24
+ }): SupaQuery<T>;
25
+ single(): PromiseLike<SupaResp<T>>;
26
+ }
27
+ interface SupaTable<T> {
28
+ select(columns?: string): SupaQuery<T>;
29
+ upsert(values: Partial<T> | Partial<T>[]): PromiseLike<SupaResp<T[]>>;
30
+ update(values: Partial<T>): SupaQuery<T>;
31
+ delete(): SupaQuery<T>;
32
+ }
33
+ /** Structural view of a @supabase/supabase-js client. */
34
+ export interface SupabaseClientLike {
35
+ from(relation: string): SupaTable<SessionRow>;
36
+ }
37
+ export declare class SupabaseSessionStore implements SessionStoreLike {
38
+ private readonly supabase;
39
+ private readonly table;
40
+ constructor(supabase: SupabaseClientLike, table?: string);
41
+ get(sessionId: string): Promise<StoredSession | null>;
42
+ load(sessionId: string): Promise<ChatMsg[] | null>;
43
+ save(sessionId: string, transcript: ChatMsg[], meta?: {
44
+ title?: string;
45
+ model?: string;
46
+ }): Promise<void>;
47
+ list(): Promise<SessionMeta[]>;
48
+ rename(sessionId: string, title: string): Promise<void>;
49
+ remove(sessionId: string): Promise<void>;
50
+ }
51
+ export {};
@@ -0,0 +1,65 @@
1
+ export const SUPABASE_SCHEMA = `
2
+ create table if not exists sessions (
3
+ id text primary key,
4
+ title text,
5
+ model text,
6
+ created_at bigint not null,
7
+ updated_at bigint not null,
8
+ message_count int not null default 0,
9
+ transcript jsonb not null default '[]'::jsonb
10
+ );
11
+ create index if not exists sessions_updated_at_idx on sessions (updated_at desc);
12
+ `.trim();
13
+ const toMeta = (r) => ({
14
+ sessionId: r.id,
15
+ title: r.title ?? undefined,
16
+ model: r.model ?? undefined,
17
+ createdAt: Number(r.created_at),
18
+ updatedAt: Number(r.updated_at),
19
+ messageCount: Number(r.message_count),
20
+ });
21
+ export class SupabaseSessionStore {
22
+ constructor(supabase, table = 'sessions') {
23
+ this.supabase = supabase;
24
+ this.table = table;
25
+ }
26
+ async get(sessionId) {
27
+ const { data } = await this.supabase.from(this.table).select('*').eq('id', sessionId).single();
28
+ if (!data)
29
+ return null;
30
+ return { ...toMeta(data), transcript: Array.isArray(data.transcript) ? data.transcript : [] };
31
+ }
32
+ async load(sessionId) {
33
+ const s = await this.get(sessionId);
34
+ return s ? s.transcript : null;
35
+ }
36
+ async save(sessionId, transcript, meta = {}) {
37
+ const now = Date.now();
38
+ const existing = await this.get(sessionId);
39
+ const row = {
40
+ id: sessionId,
41
+ title: meta.title ?? existing?.title ?? null,
42
+ model: meta.model ?? existing?.model ?? null,
43
+ created_at: existing?.createdAt ?? now,
44
+ updated_at: now,
45
+ message_count: transcript.length,
46
+ transcript,
47
+ };
48
+ const { error } = await this.supabase.from(this.table).upsert(row);
49
+ if (error)
50
+ throw new Error('SupabaseSessionStore.save: ' + error.message);
51
+ }
52
+ async list() {
53
+ const { data } = await this.supabase
54
+ .from(this.table)
55
+ .select('id,title,model,created_at,updated_at,message_count')
56
+ .order('updated_at', { ascending: false });
57
+ return (data ?? []).map(toMeta);
58
+ }
59
+ async rename(sessionId, title) {
60
+ await this.supabase.from(this.table).update({ title, updated_at: Date.now() }).eq('id', sessionId);
61
+ }
62
+ async remove(sessionId) {
63
+ await this.supabase.from(this.table).delete().eq('id', sessionId);
64
+ }
65
+ }
@@ -1,2 +1,7 @@
1
1
  export { SessionStore } from './store.js';
2
- export type { SessionMeta, StoredSession, SessionStoreOptions } from './types.js';
2
+ export type { SessionMeta, StoredSession, SessionStoreOptions, SessionStoreLike } from './types.js';
3
+ export { MemorySessionStore } from './adapters/memory.js';
4
+ export { KVSessionStore, type KVClientLike } from './adapters/kv.js';
5
+ export { RedisSessionStore, type RedisClientLike } from './adapters/redis.js';
6
+ export { PostgresSessionStore, POSTGRES_SCHEMA, type PgRunnerLike } from './adapters/postgres.js';
7
+ export { SupabaseSessionStore, SUPABASE_SCHEMA, type SupabaseClientLike } from './adapters/supabase.js';
@@ -4,3 +4,10 @@
4
4
  // IndexedDB (via Dexie), enabling listSessions / resume / fork / rename across
5
5
  // reloads.
6
6
  export { SessionStore } from './store.js';
7
+ // Pluggable backends for the survivor / serverless persistence (structural
8
+ // clients — the DB packages stay optional).
9
+ export { MemorySessionStore } from './adapters/memory.js';
10
+ export { KVSessionStore } from './adapters/kv.js';
11
+ export { RedisSessionStore } from './adapters/redis.js';
12
+ export { PostgresSessionStore, POSTGRES_SCHEMA } from './adapters/postgres.js';
13
+ export { SupabaseSessionStore, SUPABASE_SCHEMA } from './adapters/supabase.js';
@@ -1,6 +1,6 @@
1
1
  import type { ChatMsg } from '../types/index.js';
2
- import type { SessionMeta, SessionStoreOptions, StoredSession } from './types.js';
3
- export declare class SessionStore {
2
+ import type { SessionMeta, SessionStoreOptions, StoredSession, SessionStoreLike } from './types.js';
3
+ export declare class SessionStore implements SessionStoreLike {
4
4
  private readonly dbName;
5
5
  private db;
6
6
  private opening;
@@ -4,6 +4,8 @@
4
4
  //
5
5
  // `dexie` is an OPTIONAL peer dependency — imported dynamically so this module
6
6
  // loads even when Dexie isn't installed (the error surfaces only on first use).
7
+ // The built-in IndexedDB (Dexie) session store. `implements SessionStoreLike`
8
+ // guarantees it stays compatible with the pluggable interface the agent expects.
7
9
  export class SessionStore {
8
10
  constructor(options = {}) {
9
11
  this.db = null;
@@ -20,3 +20,25 @@ export interface SessionStoreOptions {
20
20
  /** IndexedDB database name. Default: 'bcs-sessions'. */
21
21
  dbName?: string;
22
22
  }
23
+ /**
24
+ * Minimal pluggable session store — implement this to back persistence with any
25
+ * database (Supabase, Neon/Postgres, Vercel KV, Upstash Redis, files, …). The
26
+ * agent loop only needs `load` + `save`; the rest are for UIs/management.
27
+ */
28
+ export interface SessionStoreLike {
29
+ /** Return the stored transcript for a session, or null if none. */
30
+ load(sessionId: string): Promise<ChatMsg[] | null>;
31
+ /** Persist the transcript (called after each turn + on a paused boundary). */
32
+ save(sessionId: string, transcript: ChatMsg[], meta?: {
33
+ title?: string;
34
+ model?: string;
35
+ }): Promise<void>;
36
+ /** Optional: list sessions (metadata only). */
37
+ list?(): Promise<SessionMeta[]>;
38
+ /** Optional: full stored session. */
39
+ get?(sessionId: string): Promise<StoredSession | null>;
40
+ /** Optional: rename a session. */
41
+ rename?(sessionId: string, title: string): Promise<void>;
42
+ /** Optional: delete a session. */
43
+ remove?(sessionId: string): Promise<void>;
44
+ }
@@ -0,0 +1,2 @@
1
+ import type { Tool } from './types.js';
2
+ export declare const askUserQuestion: Tool;
@@ -0,0 +1,54 @@
1
+ const DESCRIPTION = `Ask the user a multiple-choice question and wait for their answer. Use ONLY when you hit a decision that's genuinely the user's to make (choosing between distinct approaches, confirming ambiguous scope) and you can't resolve it from the request or sensible defaults. Provide 2-4 concrete, mutually-exclusive options. Prefer acting on a reasonable default over asking.`;
2
+ export const askUserQuestion = {
3
+ def: {
4
+ type: 'function',
5
+ function: {
6
+ name: 'ask_user_question',
7
+ description: DESCRIPTION,
8
+ parameters: {
9
+ type: 'object',
10
+ properties: {
11
+ question: { type: 'string', description: 'The question to ask the user.' },
12
+ header: { type: 'string', description: 'Very short label/chip for the question (max ~12 chars).' },
13
+ options: {
14
+ type: 'array',
15
+ description: '2-4 options the user can choose from.',
16
+ items: {
17
+ type: 'object',
18
+ properties: {
19
+ label: { type: 'string', description: 'Concise choice text (1-5 words).' },
20
+ description: { type: 'string', description: 'What this option means / its trade-off.' },
21
+ },
22
+ required: ['label'],
23
+ },
24
+ },
25
+ multiSelect: { type: 'boolean', description: 'Allow selecting multiple options.' },
26
+ },
27
+ required: ['question', 'options'],
28
+ },
29
+ },
30
+ },
31
+ async run(input, ctx) {
32
+ if (!ctx.askUser) {
33
+ return {
34
+ content: 'Interactive questions are unavailable in this environment. Choose the most reasonable option yourself, state the assumption, and continue.',
35
+ isError: true,
36
+ };
37
+ }
38
+ const question = String(input.question ?? '').trim();
39
+ const raw = Array.isArray(input.options) ? input.options : [];
40
+ const options = raw.map((o) => typeof o === 'string'
41
+ ? { label: o }
42
+ : { label: String(o.label ?? ''), description: o.description ? String(o.description) : undefined });
43
+ if (!question || !options.length)
44
+ return { content: 'Error: `question` and `options` are required.', isError: true };
45
+ const answer = await ctx.askUser({
46
+ question,
47
+ header: input.header ? String(input.header) : undefined,
48
+ options,
49
+ multiSelect: !!input.multiSelect,
50
+ });
51
+ const chosen = Array.isArray(answer) ? answer.join(', ') : String(answer);
52
+ return { content: `User answered "${question}" → ${chosen || '(no selection)'}` };
53
+ },
54
+ };
@@ -20,6 +20,7 @@ export { bash, readFile, writeFile, editFile, deleteFile, listFiles, glob, grep
20
20
  export { multiEdit, notebookEdit, todoWrite, webFetch, webSearch, toolSearch, config };
21
21
  export { walk, globToRegExp, joinPath, DEFAULT_IGNORE } from './walk.js';
22
22
  export { defineTool, type DefineToolSpec } from './define.js';
23
+ export { askUserQuestion } from './ask_user.js';
23
24
  /** Every built-in Claude Code tool, ready to pass to `query()`. */
24
25
  export declare const ALL_CLAUDE_CODE_TOOLS: Tool[];
25
26
  /** Extract the OpenAI-shape definitions to send to the LLM. */
@@ -17,6 +17,7 @@ export { bash, readFile, writeFile, editFile, deleteFile, listFiles, glob, grep
17
17
  export { multiEdit, notebookEdit, todoWrite, webFetch, webSearch, toolSearch, config };
18
18
  export { walk, globToRegExp, joinPath, DEFAULT_IGNORE } from './walk.js';
19
19
  export { defineTool } from './define.js';
20
+ export { askUserQuestion } from './ask_user.js';
20
21
  /** Every built-in Claude Code tool, ready to pass to `query()`. */
21
22
  export const ALL_CLAUDE_CODE_TOOLS = [
22
23
  bash,
@@ -41,6 +41,16 @@ export interface ToolContext {
41
41
  text: string;
42
42
  isError?: boolean;
43
43
  }>;
44
+ /** Ask the user a multiple-choice question (wired from query({ onAskUser })). */
45
+ askUser?: (q: {
46
+ question: string;
47
+ header?: string;
48
+ options: Array<{
49
+ label: string;
50
+ description?: string;
51
+ }>;
52
+ multiSelect?: boolean;
53
+ }) => Promise<string | string[]>;
44
54
  /** Background task manager, present when background tasks are enabled. */
45
55
  background?: import('../background/manager.js').BackgroundTaskManager;
46
56
  /** Inter-agent mailbox, present when teammates are enabled. Shared with sub-agents. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyclaude-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Standalone, browser-compatible SDK providing Claude Code agent capabilities (tools, tool loop, multi-turn, MCP, sub-agents, sessions) against any OpenAI/Anthropic-compatible LLM endpoint. Runs in the browser (WebContainer), Node, and Bun — no backend required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",