@byfriends/agent-core 0.1.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.
@@ -0,0 +1,813 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { APIConnectionError, APIStatusError, APITimeoutError, ChatProviderError } from "@byfriends/kosong";
4
+ import { appendFile, cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
+ import { z } from "zod";
6
+ //#region src/errors/codes.ts
7
+ /**
8
+ * Error codes for Byf Core's public error protocol.
9
+ *
10
+ * `ErrorCodes` is the source of truth for every code Byf Core may emit.
11
+ * Downstream consumers (SDK, RPC clients, telemetry, agent-facing docs)
12
+ * should depend on these string values rather than on class identity.
13
+ *
14
+ * Codes follow `domain.reason`. Adding a code is a minor change; renaming
15
+ * or removing one is a major change.
16
+ */
17
+ const ErrorCodes = {
18
+ CONFIG_INVALID: "config.invalid",
19
+ SESSION_NOT_FOUND: "session.not_found",
20
+ SESSION_ALREADY_EXISTS: "session.already_exists",
21
+ SESSION_ID_INVALID: "session.id_invalid",
22
+ SESSION_ID_REQUIRED: "session.id_required",
23
+ SESSION_ID_EMPTY: "session.id_empty",
24
+ SESSION_TITLE_EMPTY: "session.title_empty",
25
+ SESSION_STATE_NOT_FOUND: "session.state_not_found",
26
+ SESSION_STATE_INVALID: "session.state_invalid",
27
+ SESSION_FORK_ACTIVE_TURN: "session.fork_active_turn",
28
+ SESSION_EXPORT_NOT_FOUND: "session.export_not_found",
29
+ SESSION_EXPORT_MISSING_VERSION: "session.export_missing_version",
30
+ SESSION_CLOSED: "session.closed",
31
+ SESSION_PERMISSION_MODE_INVALID: "session.permission_mode_invalid",
32
+ SESSION_THINKING_EMPTY: "session.thinking_empty",
33
+ SESSION_MODEL_EMPTY: "session.model_empty",
34
+ SESSION_PLAN_MODE_INVALID: "session.plan_mode_invalid",
35
+ SESSION_APPROVAL_HANDLER_ERROR: "session.approval_handler_error",
36
+ SESSION_QUESTION_HANDLER_ERROR: "session.question_handler_error",
37
+ SESSION_INIT_FAILED: "session.init_failed",
38
+ AGENT_NOT_FOUND: "agent.not_found",
39
+ TURN_AGENT_BUSY: "turn.agent_busy",
40
+ MODEL_NOT_CONFIGURED: "model.not_configured",
41
+ MODEL_CONFIG_INVALID: "model.config_invalid",
42
+ AUTH_LOGIN_REQUIRED: "auth.login_required",
43
+ CONTEXT_OVERFLOW: "context.overflow",
44
+ LOOP_MAX_STEPS_EXCEEDED: "loop.max_steps_exceeded",
45
+ PROVIDER_API_ERROR: "provider.api_error",
46
+ PROVIDER_RATE_LIMIT: "provider.rate_limit",
47
+ PROVIDER_AUTH_ERROR: "provider.auth_error",
48
+ PROVIDER_CONNECTION_ERROR: "provider.connection_error",
49
+ SKILL_NOT_FOUND: "skill.not_found",
50
+ SKILL_TYPE_UNSUPPORTED: "skill.type_unsupported",
51
+ SKILL_NAME_EMPTY: "skill.name_empty",
52
+ RECORDS_WRITE_FAILED: "records.write_failed",
53
+ COMPACTION_FAILED: "compaction.failed",
54
+ BACKGROUND_TASK_ID_EMPTY: "background.task_id_empty",
55
+ MCP_SERVER_NOT_FOUND: "mcp.server_not_found",
56
+ MCP_SERVER_DISABLED: "mcp.server_disabled",
57
+ MCP_STARTUP_FAILED: "mcp.startup_failed",
58
+ MCP_TOOL_NAME_COLLISION: "mcp.tool_name_collision",
59
+ REQUEST_INVALID: "request.invalid",
60
+ REQUEST_WORK_DIR_REQUIRED: "request.work_dir_required",
61
+ REQUEST_PROMPT_INPUT_EMPTY: "request.prompt_input_empty",
62
+ SHELL_GIT_BASH_NOT_FOUND: "shell.git_bash_not_found",
63
+ NOT_IMPLEMENTED: "not_implemented",
64
+ INTERNAL: "internal"
65
+ };
66
+ const BYF_ERROR_INFO = {
67
+ "config.invalid": {
68
+ title: "Invalid configuration",
69
+ retryable: false,
70
+ public: true,
71
+ action: "Check config.toml and provider/model settings."
72
+ },
73
+ "session.not_found": {
74
+ title: "Session not found",
75
+ retryable: false,
76
+ public: true,
77
+ action: "Check the session id or list available sessions."
78
+ },
79
+ "session.already_exists": {
80
+ title: "Session already exists",
81
+ retryable: false,
82
+ public: true,
83
+ action: "Use a different session id or remove the existing session first."
84
+ },
85
+ "session.id_invalid": {
86
+ title: "Invalid session id",
87
+ retryable: false,
88
+ public: true,
89
+ action: "Use a session id without path-traversal characters."
90
+ },
91
+ "session.id_required": {
92
+ title: "Session id required",
93
+ retryable: false,
94
+ public: true,
95
+ action: "Provide a session id when calling this method."
96
+ },
97
+ "session.id_empty": {
98
+ title: "Session id is empty",
99
+ retryable: false,
100
+ public: true,
101
+ action: "Provide a non-empty session id."
102
+ },
103
+ "session.title_empty": {
104
+ title: "Session title is empty",
105
+ retryable: false,
106
+ public: true,
107
+ action: "Provide a non-empty session title."
108
+ },
109
+ "session.state_not_found": {
110
+ title: "Session state missing",
111
+ retryable: false,
112
+ public: true,
113
+ action: "The session directory is corrupted or missing state.json."
114
+ },
115
+ "session.state_invalid": {
116
+ title: "Session state invalid",
117
+ retryable: false,
118
+ public: true,
119
+ action: "The session state.json is corrupted; remove the session or repair the file."
120
+ },
121
+ "session.fork_active_turn": {
122
+ title: "Cannot fork session during active turn",
123
+ retryable: true,
124
+ public: true,
125
+ action: "Wait for the active turn to complete before forking."
126
+ },
127
+ "session.export_not_found": {
128
+ title: "Session export directory missing",
129
+ retryable: false,
130
+ public: true,
131
+ action: "The session has not been persisted to disk yet."
132
+ },
133
+ "session.export_missing_version": {
134
+ title: "Export version is missing",
135
+ retryable: false,
136
+ public: true,
137
+ action: "Provide a version when exporting the session."
138
+ },
139
+ "session.closed": {
140
+ title: "Session is closed",
141
+ retryable: false,
142
+ public: true,
143
+ action: "Create a new session."
144
+ },
145
+ "session.permission_mode_invalid": {
146
+ title: "Invalid permission mode",
147
+ retryable: false,
148
+ public: true,
149
+ action: "Use one of: yolo / manual / auto."
150
+ },
151
+ "session.thinking_empty": {
152
+ title: "Thinking value is empty",
153
+ retryable: false,
154
+ public: true,
155
+ action: "Provide a non-empty thinking option."
156
+ },
157
+ "session.model_empty": {
158
+ title: "Model is empty",
159
+ retryable: false,
160
+ public: true,
161
+ action: "Provide a non-empty model identifier."
162
+ },
163
+ "session.plan_mode_invalid": {
164
+ title: "Invalid plan mode",
165
+ retryable: false,
166
+ public: true,
167
+ action: "Provide a boolean plan mode."
168
+ },
169
+ "session.approval_handler_error": {
170
+ title: "Approval handler threw",
171
+ retryable: false,
172
+ public: true,
173
+ action: "Inspect the SDK approval handler for an unhandled exception."
174
+ },
175
+ "session.question_handler_error": {
176
+ title: "Question handler threw",
177
+ retryable: false,
178
+ public: true,
179
+ action: "Inspect the SDK question handler for an unhandled exception."
180
+ },
181
+ "session.init_failed": {
182
+ title: "Session init failed",
183
+ retryable: false,
184
+ public: false,
185
+ action: "Review the init failure details and try again."
186
+ },
187
+ "agent.not_found": {
188
+ title: "Agent not found",
189
+ retryable: false,
190
+ public: true,
191
+ action: "Check the agent id or list available agents."
192
+ },
193
+ "turn.agent_busy": {
194
+ title: "Agent is busy",
195
+ retryable: true,
196
+ public: true,
197
+ action: "Wait for the current turn to finish or steer it."
198
+ },
199
+ "model.not_configured": {
200
+ title: "No model configured",
201
+ retryable: false,
202
+ public: true,
203
+ action: "Set a default model in config.toml or via setModel."
204
+ },
205
+ "model.config_invalid": {
206
+ title: "Invalid model configuration",
207
+ retryable: false,
208
+ public: true,
209
+ action: "Check the model and provider entries in config.toml."
210
+ },
211
+ "auth.login_required": {
212
+ title: "Login required",
213
+ retryable: false,
214
+ public: true,
215
+ action: "Run the login flow for the provider before retrying."
216
+ },
217
+ "context.overflow": {
218
+ title: "Context window overflow",
219
+ retryable: true,
220
+ public: true,
221
+ action: "Compact the conversation or start a new session."
222
+ },
223
+ "loop.max_steps_exceeded": {
224
+ title: "Turn exceeded max steps",
225
+ retryable: false,
226
+ public: true,
227
+ action: "Increase loop_control.max_steps_per_turn or split the task."
228
+ },
229
+ "provider.api_error": {
230
+ title: "Provider API error",
231
+ retryable: false,
232
+ public: true,
233
+ action: "Inspect details.statusCode / details.requestId; check provider status."
234
+ },
235
+ "provider.rate_limit": {
236
+ title: "Provider rate limit",
237
+ retryable: true,
238
+ public: true,
239
+ action: "Retry after a delay or reduce request frequency."
240
+ },
241
+ "provider.auth_error": {
242
+ title: "Provider authentication error",
243
+ retryable: false,
244
+ public: true,
245
+ action: "Re-authenticate with the provider."
246
+ },
247
+ "provider.connection_error": {
248
+ title: "Provider connection error",
249
+ retryable: true,
250
+ public: true,
251
+ action: "Check network connectivity and retry."
252
+ },
253
+ "skill.not_found": {
254
+ title: "Skill not found",
255
+ retryable: false,
256
+ public: true,
257
+ action: "List available skills via the skill registry."
258
+ },
259
+ "skill.type_unsupported": {
260
+ title: "Skill type not supported",
261
+ retryable: false,
262
+ public: true,
263
+ action: "Only inline skills can be activated by the user."
264
+ },
265
+ "skill.name_empty": {
266
+ title: "Skill name is empty",
267
+ retryable: false,
268
+ public: true,
269
+ action: "Provide a non-empty skill name."
270
+ },
271
+ "records.write_failed": {
272
+ title: "Failed to write records",
273
+ retryable: true,
274
+ public: true,
275
+ action: "Check disk space and permissions on the session directory."
276
+ },
277
+ "compaction.failed": {
278
+ title: "Compaction failed",
279
+ retryable: false,
280
+ public: true,
281
+ action: "Inspect logs and consider increasing compaction limits."
282
+ },
283
+ "background.task_id_empty": {
284
+ title: "Background task id is empty",
285
+ retryable: false,
286
+ public: true,
287
+ action: "Provide a non-empty task id."
288
+ },
289
+ "mcp.server_not_found": {
290
+ title: "MCP server not found",
291
+ retryable: false,
292
+ public: true,
293
+ action: "List configured MCP servers and check the requested name."
294
+ },
295
+ "mcp.server_disabled": {
296
+ title: "MCP server is disabled",
297
+ retryable: false,
298
+ public: true,
299
+ action: "Enable the MCP server entry in config before reconnecting."
300
+ },
301
+ "mcp.startup_failed": {
302
+ title: "MCP server startup failed",
303
+ retryable: true,
304
+ public: true,
305
+ action: "Inspect the MCP server log or call reconnect once the server is healthy."
306
+ },
307
+ "mcp.tool_name_collision": {
308
+ title: "MCP tool name collision",
309
+ retryable: false,
310
+ public: true,
311
+ action: "Rename one of the colliding MCP tools or servers so their qualified names are unique."
312
+ },
313
+ "request.invalid": {
314
+ title: "Invalid request",
315
+ retryable: false,
316
+ public: true,
317
+ action: "Check the input shape matches the API contract."
318
+ },
319
+ "request.work_dir_required": {
320
+ title: "workDir is required",
321
+ retryable: false,
322
+ public: true,
323
+ action: "Provide workDir in the request payload."
324
+ },
325
+ "request.prompt_input_empty": {
326
+ title: "Prompt input is empty",
327
+ retryable: false,
328
+ public: true,
329
+ action: "Provide non-empty prompt input."
330
+ },
331
+ "shell.git_bash_not_found": {
332
+ title: "Git Bash not found",
333
+ retryable: false,
334
+ public: true,
335
+ action: "Install Git for Windows from https://gitforwindows.org/ or set BYF_SHELL_PATH to a bash.exe."
336
+ },
337
+ not_implemented: {
338
+ title: "Not implemented",
339
+ retryable: false,
340
+ public: true,
341
+ action: "This feature is not implemented yet."
342
+ },
343
+ internal: {
344
+ title: "Internal error",
345
+ retryable: false,
346
+ public: true,
347
+ action: "Inspect logs or report the issue with diagnostics."
348
+ }
349
+ };
350
+ //#endregion
351
+ //#region src/errors/classes.ts
352
+ /**
353
+ * The single Byf error class.
354
+ *
355
+ * Discrimination is always by `code`. Cross-process consumers receive
356
+ * `ByfErrorPayload` and must branch on `code` rather than class identity.
357
+ */
358
+ var ByfError = class extends Error {
359
+ code;
360
+ details;
361
+ cause;
362
+ constructor(code, message, options = {}) {
363
+ super(message);
364
+ this.name = "ByfError";
365
+ this.code = code;
366
+ this.details = options.details;
367
+ this.cause = options.cause;
368
+ }
369
+ };
370
+ //#endregion
371
+ //#region src/errors/serialize.ts
372
+ /** Type guard for ByfError. */
373
+ function isByfError(error) {
374
+ return error instanceof ByfError;
375
+ }
376
+ /**
377
+ * Build a ByfErrorPayload directly from a code + message (no Error instance
378
+ * needed). Use this for synthetic error events that are signaled, not thrown
379
+ * -- e.g. "turn busy" or "compaction failed". `retryable` is filled from
380
+ * BYF_ERROR_INFO so callers cannot drift out of sync with the registry.
381
+ */
382
+ function makeErrorPayload(code, message, options) {
383
+ return {
384
+ code,
385
+ message,
386
+ name: options?.name,
387
+ details: options?.details,
388
+ retryable: BYF_ERROR_INFO[code].retryable
389
+ };
390
+ }
391
+ /**
392
+ * Normalize any value into a ByfErrorPayload.
393
+ *
394
+ * Recognized errors:
395
+ * - `ByfError`: passthrough.
396
+ * - `APIStatusError`: 429 -> rate_limit, 401 -> auth_error, otherwise -> api_error.
397
+ * - `APIConnectionError` / `APITimeoutError`: connection_error.
398
+ * - `ChatProviderError`: api_error.
399
+ * - Heuristic "Model not set" / "Provider not set" messages: model.not_configured.
400
+ *
401
+ * Anything else collapses to `internal`. We never echo `cause` or stack on
402
+ * the wire.
403
+ */
404
+ function toByfErrorPayload(error) {
405
+ if (isByfError(error)) return {
406
+ code: error.code,
407
+ message: error.message,
408
+ name: error.name,
409
+ details: error.details,
410
+ retryable: BYF_ERROR_INFO[error.code].retryable
411
+ };
412
+ if (error instanceof APIStatusError) {
413
+ const code = error.statusCode === 429 ? ErrorCodes.PROVIDER_RATE_LIMIT : error.statusCode === 401 ? ErrorCodes.PROVIDER_AUTH_ERROR : ErrorCodes.PROVIDER_API_ERROR;
414
+ return {
415
+ code,
416
+ message: error.message,
417
+ name: error.name,
418
+ details: {
419
+ statusCode: error.statusCode,
420
+ requestId: error.requestId
421
+ },
422
+ retryable: BYF_ERROR_INFO[code].retryable
423
+ };
424
+ }
425
+ if (error instanceof APIConnectionError || error instanceof APITimeoutError) return {
426
+ code: ErrorCodes.PROVIDER_CONNECTION_ERROR,
427
+ message: error.message,
428
+ name: error.name,
429
+ retryable: BYF_ERROR_INFO[ErrorCodes.PROVIDER_CONNECTION_ERROR].retryable
430
+ };
431
+ if (error instanceof ChatProviderError) return {
432
+ code: ErrorCodes.PROVIDER_API_ERROR,
433
+ message: error.message,
434
+ name: error.name,
435
+ retryable: BYF_ERROR_INFO[ErrorCodes.PROVIDER_API_ERROR].retryable
436
+ };
437
+ if (error instanceof Error) {
438
+ if (error.message === "Model not set" || error.message === "Provider not set") return {
439
+ code: ErrorCodes.MODEL_NOT_CONFIGURED,
440
+ message: error.message,
441
+ name: error.name,
442
+ retryable: BYF_ERROR_INFO[ErrorCodes.MODEL_NOT_CONFIGURED].retryable
443
+ };
444
+ return {
445
+ code: ErrorCodes.INTERNAL,
446
+ message: error.message,
447
+ name: error.name,
448
+ retryable: BYF_ERROR_INFO[ErrorCodes.INTERNAL].retryable
449
+ };
450
+ }
451
+ return {
452
+ code: ErrorCodes.INTERNAL,
453
+ message: String(error),
454
+ retryable: BYF_ERROR_INFO[ErrorCodes.INTERNAL].retryable
455
+ };
456
+ }
457
+ /**
458
+ * Rehydrate a ByfErrorPayload into a ByfError. Used by SDK boundary code
459
+ * receiving errors over RPC to re-surface them with a real class so
460
+ * in-process consumers can still use `instanceof`.
461
+ */
462
+ function fromByfErrorPayload(payload) {
463
+ return new ByfError(payload.code, payload.message, { details: payload.details });
464
+ }
465
+ //#endregion
466
+ //#region src/session/store/session-index.ts
467
+ function sessionIndexPath(homeDir) {
468
+ return join(homeDir, "session_index.jsonl");
469
+ }
470
+ async function appendSessionIndexEntry(homeDir, entry) {
471
+ const indexPath = sessionIndexPath(homeDir);
472
+ await mkdir(dirname(indexPath), {
473
+ recursive: true,
474
+ mode: 448
475
+ });
476
+ await appendFile(indexPath, `${JSON.stringify(entry)}\n`, "utf-8");
477
+ }
478
+ async function readSessionIndex(homeDir, sessionsDir) {
479
+ let raw;
480
+ try {
481
+ raw = await readFile(sessionIndexPath(homeDir), "utf-8");
482
+ } catch {
483
+ return /* @__PURE__ */ new Map();
484
+ }
485
+ const result = /* @__PURE__ */ new Map();
486
+ for (const line of raw.split(/\r?\n/)) {
487
+ const trimmed = line.trim();
488
+ if (trimmed === "") continue;
489
+ const entry = parseIndexLine(trimmed);
490
+ if (entry === void 0) continue;
491
+ const sessionDir = resolve(entry.sessionDir);
492
+ if (!isAbsolute(entry.sessionDir)) continue;
493
+ if (!isAbsolute(entry.workDir)) continue;
494
+ if (!isPathInside(sessionsDir, sessionDir)) continue;
495
+ if (basename(sessionDir) !== entry.sessionId) continue;
496
+ result.set(entry.sessionId, {
497
+ sessionId: entry.sessionId,
498
+ sessionDir,
499
+ workDir: resolve(entry.workDir)
500
+ });
501
+ }
502
+ return result;
503
+ }
504
+ function parseIndexLine(line) {
505
+ try {
506
+ const parsed = JSON.parse(line);
507
+ if (typeof parsed !== "object" || parsed === null) return void 0;
508
+ const entry = parsed;
509
+ if (typeof entry.sessionId !== "string" || typeof entry.sessionDir !== "string" || typeof entry.workDir !== "string") return;
510
+ return {
511
+ sessionId: entry.sessionId,
512
+ sessionDir: entry.sessionDir,
513
+ workDir: entry.workDir
514
+ };
515
+ } catch {
516
+ return;
517
+ }
518
+ }
519
+ function isPathInside(parent, child) {
520
+ const rel = relative(resolve(parent), resolve(child));
521
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
522
+ }
523
+ //#endregion
524
+ //#region src/utils/workdir-slug.ts
525
+ const MAX_WORKDIR_SLUG_LENGTH = 40;
526
+ function slugifyWorkDirName(name) {
527
+ const slug = name.toLowerCase().replaceAll(/[^a-z0-9._-]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, MAX_WORKDIR_SLUG_LENGTH).replaceAll(/^-+|-+$/g, "");
528
+ return slug === "" || slug === "." || slug === ".." ? "workspace" : slug;
529
+ }
530
+ //#endregion
531
+ //#region src/session/store/workdir-key.ts
532
+ const WORKDIR_KEY_PREFIX = "wd_";
533
+ const HASH_LENGTH = 12;
534
+ function normalizeWorkDir(workDir) {
535
+ return resolve(workDir);
536
+ }
537
+ function encodeWorkDirKey(workDir) {
538
+ const normalized = normalizeWorkDir(workDir);
539
+ return `${WORKDIR_KEY_PREFIX}${slugifyWorkDirName(basename(normalized))}_${createHash("sha256").update(normalized).digest("hex").slice(0, HASH_LENGTH)}`;
540
+ }
541
+ //#endregion
542
+ //#region src/session/store/session-store.ts
543
+ const SessionSummaryStateSchema = z.object({
544
+ customTitle: z.string().optional(),
545
+ isCustomTitle: z.boolean().optional(),
546
+ lastPrompt: z.string().optional(),
547
+ title: z.string().optional(),
548
+ custom: z.record(z.string(), z.unknown()).optional()
549
+ });
550
+ var SessionStore = class {
551
+ homeDir;
552
+ sessionsDir;
553
+ constructor(homeDir, _options = {}) {
554
+ this.homeDir = homeDir;
555
+ this.sessionsDir = join(homeDir, "sessions");
556
+ }
557
+ sessionDirFor(input) {
558
+ assertSafeSessionId(input.id);
559
+ return join(this.sessionsDir, encodeWorkDirKey(normalizeWorkDir(input.workDir)), input.id);
560
+ }
561
+ async create(input) {
562
+ assertSafeSessionId(input.id);
563
+ const workDir = normalizeWorkDir(input.workDir);
564
+ if (await this.findSessionEntry(input.id) !== void 0) throw new ByfError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.id}" already exists`);
565
+ const dir = this.sessionDirFor({
566
+ id: input.id,
567
+ workDir
568
+ });
569
+ if (await isDirectory(dir)) throw new ByfError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.id}" already exists`);
570
+ await mkdir(dir, {
571
+ recursive: true,
572
+ mode: 448
573
+ });
574
+ await appendSessionIndexEntry(this.homeDir, {
575
+ sessionId: input.id,
576
+ sessionDir: dir,
577
+ workDir
578
+ });
579
+ return this.summaryFromDir(input.id, dir, workDir);
580
+ }
581
+ async fork(input) {
582
+ const source = await this.findExistingSessionEntry(input.sourceId);
583
+ assertSafeSessionId(input.targetId);
584
+ if (await this.findSessionEntry(input.targetId) !== void 0) throw new ByfError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.targetId}" already exists`);
585
+ const targetDir = this.sessionDirFor({
586
+ id: input.targetId,
587
+ workDir: source.workDir
588
+ });
589
+ if (await isDirectory(targetDir)) throw new ByfError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.targetId}" already exists`);
590
+ await mkdir(dirname(targetDir), {
591
+ recursive: true,
592
+ mode: 448
593
+ });
594
+ try {
595
+ await cp(source.sessionDir, targetDir, {
596
+ recursive: true,
597
+ force: false,
598
+ errorOnExist: true
599
+ });
600
+ await this.writeForkedState(input, source.sessionDir, targetDir);
601
+ const summary = await this.summaryFromDir(input.targetId, targetDir, source.workDir);
602
+ await appendSessionIndexEntry(this.homeDir, {
603
+ sessionId: input.targetId,
604
+ sessionDir: targetDir,
605
+ workDir: source.workDir
606
+ });
607
+ return summary;
608
+ } catch (error) {
609
+ await rm(targetDir, {
610
+ recursive: true,
611
+ force: true
612
+ }).catch(() => {});
613
+ throw error;
614
+ }
615
+ }
616
+ async get(id) {
617
+ const entry = await this.findExistingSessionEntry(id);
618
+ return this.summaryFromDir(id, entry.sessionDir, entry.workDir);
619
+ }
620
+ async rename(id, title) {
621
+ const normalized = title.trim();
622
+ if (normalized.length === 0) throw new ByfError(ErrorCodes.SESSION_TITLE_EMPTY, "Session title cannot be empty");
623
+ const statePath = join((await this.findExistingSessionEntry(id)).sessionDir, "state.json");
624
+ let parsed;
625
+ try {
626
+ parsed = JSON.parse(await readFile(statePath, "utf-8"));
627
+ } catch (error) {
628
+ throw new ByfError(ErrorCodes.SESSION_STATE_NOT_FOUND, `Session "${id}" state.json was not found`, { cause: error });
629
+ }
630
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new ByfError(ErrorCodes.SESSION_STATE_INVALID, `Session "${id}" state.json is invalid`);
631
+ const next = {
632
+ ...parsed,
633
+ title: normalized,
634
+ isCustomTitle: true
635
+ };
636
+ await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, "utf-8");
637
+ }
638
+ async list(options) {
639
+ const workDir = normalizeWorkDir(options.workDir);
640
+ const bucketDir = join(this.sessionsDir, encodeWorkDirKey(workDir));
641
+ let entries;
642
+ try {
643
+ entries = await readdir(bucketDir, { withFileTypes: true });
644
+ } catch {
645
+ return [];
646
+ }
647
+ const sessions = [];
648
+ for (const entry of entries) {
649
+ if (!entry.isDirectory()) continue;
650
+ const id = entry.name;
651
+ if (!isSafeSessionId(id)) continue;
652
+ const dir = join(bucketDir, id);
653
+ sessions.push(await this.summaryFromDir(id, dir, workDir));
654
+ }
655
+ sessions.sort(compareSessionSummary);
656
+ return sessions;
657
+ }
658
+ async assertDirectory(id) {
659
+ return (await this.findExistingSessionEntry(id)).sessionDir;
660
+ }
661
+ async findSessionEntry(id) {
662
+ if (!isSafeSessionId(id)) return void 0;
663
+ return (await readSessionIndex(this.homeDir, this.sessionsDir)).get(id);
664
+ }
665
+ async findExistingSessionEntry(id) {
666
+ const entry = await this.findSessionEntry(id);
667
+ if (entry !== void 0 && await isDirectory(entry.sessionDir)) return entry;
668
+ throw new ByfError(ErrorCodes.SESSION_NOT_FOUND, `Session "${id}" was not found`, { details: { sessionId: id } });
669
+ }
670
+ async writeForkedState(input, sourceDir, targetDir) {
671
+ const statePath = join(targetDir, "state.json");
672
+ let parsed;
673
+ try {
674
+ parsed = JSON.parse(await readFile(statePath, "utf-8"));
675
+ } catch (error) {
676
+ throw new ByfError(ErrorCodes.SESSION_STATE_NOT_FOUND, `Session "${input.sourceId}" state.json was not found`, { cause: error });
677
+ }
678
+ if (!isRecord(parsed)) throw new ByfError(ErrorCodes.SESSION_STATE_INVALID, `Session "${input.sourceId}" state.json is invalid`);
679
+ const title = normalizeForkTitle(input.title, parsed["title"]);
680
+ const now = (/* @__PURE__ */ new Date()).toISOString();
681
+ const next = {
682
+ ...parsed,
683
+ createdAt: now,
684
+ updatedAt: now,
685
+ title,
686
+ isCustomTitle: input.title === void 0 ? parsed["isCustomTitle"] === true : true,
687
+ forkedFrom: input.sourceId,
688
+ agents: rewriteAgentHomedirs(parsed["agents"], sourceDir, targetDir),
689
+ custom: Object.assign({}, isRecord(parsed["custom"]) ? parsed["custom"] : {}, input.metadata)
690
+ };
691
+ await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, "utf-8");
692
+ }
693
+ async summaryFromDir(id, sessionDir, workDir) {
694
+ const dirStat = await stat(sessionDir);
695
+ const state = await readOptionalState(sessionDir);
696
+ const [stateInfo, wireInfo, agentsWireMtime] = await Promise.all([
697
+ statIfExists(join(sessionDir, "state.json")),
698
+ statIfExists(join(sessionDir, "wire.jsonl")),
699
+ latestAgentWireMtime(sessionDir)
700
+ ]);
701
+ return {
702
+ id,
703
+ workDir,
704
+ sessionDir,
705
+ createdAt: timestampOrFallback(dirStat.birthtimeMs, dirStat.ctimeMs),
706
+ updatedAt: Math.max(dirStat.mtimeMs, stateInfo?.mtimeMs ?? 0, wireInfo?.mtimeMs ?? 0, agentsWireMtime ?? 0),
707
+ title: titleFromState(state),
708
+ lastPrompt: state?.lastPrompt,
709
+ metadata: metadataFromState(state)
710
+ };
711
+ }
712
+ };
713
+ function metadataFromState(state) {
714
+ if (state === void 0 || state.custom === void 0) return void 0;
715
+ return state.custom;
716
+ }
717
+ async function latestAgentWireMtime(sessionDir) {
718
+ const agentsDir = join(sessionDir, "agents");
719
+ let entries;
720
+ try {
721
+ entries = await readdir(agentsDir, { withFileTypes: true });
722
+ } catch {
723
+ return;
724
+ }
725
+ let latest = 0;
726
+ for (const entry of entries) {
727
+ if (!entry.isDirectory()) continue;
728
+ const wireInfo = await statIfExists(join(agentsDir, entry.name, "wire.jsonl"));
729
+ latest = Math.max(latest, wireInfo?.mtimeMs ?? 0);
730
+ }
731
+ return latest > 0 ? latest : void 0;
732
+ }
733
+ function titleFromState(state) {
734
+ if (state === void 0) return void 0;
735
+ if (typeof state.isCustomTitle === "boolean" && typeof state.title === "string") return state.title;
736
+ if (typeof state.customTitle === "string") return state.customTitle;
737
+ return typeof state.title === "string" ? state.title : void 0;
738
+ }
739
+ async function readOptionalState(sessionDir) {
740
+ try {
741
+ const parsed = JSON.parse(await readFile(join(sessionDir, "state.json"), "utf-8"));
742
+ const result = SessionSummaryStateSchema.safeParse(parsed);
743
+ return result.success ? result.data : void 0;
744
+ } catch {
745
+ return;
746
+ }
747
+ }
748
+ function normalizeForkTitle(title, fallback) {
749
+ if (title !== void 0) {
750
+ const normalized = title.trim();
751
+ if (normalized.length === 0) throw new ByfError(ErrorCodes.SESSION_TITLE_EMPTY, "Session title cannot be empty");
752
+ return normalized;
753
+ }
754
+ return typeof fallback === "string" && fallback.trim().length > 0 ? fallback : "New Session";
755
+ }
756
+ function rewriteAgentHomedirs(value, sourceDir, targetDir) {
757
+ if (!isRecord(value)) return {};
758
+ const agents = {};
759
+ for (const [agentId, agentMeta] of Object.entries(value)) {
760
+ if (!isRecord(agentMeta)) {
761
+ agents[agentId] = agentMeta;
762
+ continue;
763
+ }
764
+ const homedir = agentMeta["homedir"];
765
+ agents[agentId] = {
766
+ ...agentMeta,
767
+ homedir: typeof homedir === "string" ? remapSessionPath(homedir, sourceDir, targetDir) : homedir
768
+ };
769
+ }
770
+ return agents;
771
+ }
772
+ function remapSessionPath(value, sourceDir, targetDir) {
773
+ const rel = relative(sourceDir, value);
774
+ if (rel === "") return targetDir;
775
+ if (rel.startsWith("..") || isAbsolute(rel)) return value;
776
+ return join(targetDir, rel);
777
+ }
778
+ function isRecord(value) {
779
+ return typeof value === "object" && value !== null && !Array.isArray(value);
780
+ }
781
+ async function statIfExists(path) {
782
+ try {
783
+ return await stat(path);
784
+ } catch {
785
+ return;
786
+ }
787
+ }
788
+ async function isDirectory(path) {
789
+ try {
790
+ return (await stat(path)).isDirectory();
791
+ } catch {
792
+ return false;
793
+ }
794
+ }
795
+ function timestampOrFallback(value, fallback) {
796
+ return Number.isFinite(value) && value > 0 ? value : fallback;
797
+ }
798
+ function assertSafeSessionId(id) {
799
+ if (isSafeSessionId(id)) return;
800
+ throw new ByfError(ErrorCodes.SESSION_ID_INVALID, "Session id contains unsupported path characters");
801
+ }
802
+ function isSafeSessionId(id) {
803
+ return /^[A-Za-z0-9._-]+$/.test(id) && id !== "." && id !== "..";
804
+ }
805
+ function compareSessionSummary(a, b) {
806
+ if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt;
807
+ if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt;
808
+ if (a.id < b.id) return -1;
809
+ if (a.id > b.id) return 1;
810
+ return 0;
811
+ }
812
+ //#endregion
813
+ export { fromByfErrorPayload as a, toByfErrorPayload as c, ErrorCodes as d, sessionIndexPath as i, ByfError as l, encodeWorkDirKey as n, isByfError as o, normalizeWorkDir as r, makeErrorPayload as s, SessionStore as t, BYF_ERROR_INFO as u };