@buzzni/saycode-cli 0.9.13 → 0.9.14

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/README.md CHANGED
@@ -594,3 +594,31 @@ ACK 유실·pending은 같은 operation의 `owned-child-status`만 2~10초 간
594
594
  기록(`owned-spawns/<operationId>.json`)이 이 세션·scope·예산 key의 것이고 원장에 그 예약이 있을 때만 진행하며, 새 spawn·새 예약·환급은 없습니다.
595
595
  started면 원래 spawnId로 lineage를 기록해 `resumedOperation`과 함께 반환하고, 아직이거나 status를 조회할 수 없으면 다시 `spawn_pending`입니다(보고된 실패만 `spawn_failed`). 기록이 없거나 다른 source·scope면 `not_owned`,
596
596
  owner contact가 없으면 `owner-contact-unavailable`, 재개 명령이 초기화(인증·연결)조차 못 하면 같은 operation의 `spawn_pending`(`next` 유지)입니다. 다른 spawn 옵션과 함께 쓸 수 없습니다.
597
+
598
+ ### 재개 가능한 seq 관측 (능력 확인 후)
599
+
600
+ `happy agent whoami`의 `capabilities.resumableObservation.version=1`이 있는 direct CLI에서만 사용한다.
601
+ 서버 기능은 기본 비활성이며, 구 facade/server와 현재 broker v1에서는 이 능력을 광고하지 않는다.
602
+ 새 옵션 없는 wait/read의 기본 10분 대기와 출력은 유지된다.
603
+
604
+ ```sh
605
+ happy agent wait <id> --until turn-end --after-seq 0 --timeout 45000
606
+ happy agent wait <id> --until turn-end --after-seq 0 --resume <checkpoint> --timeout 45000
607
+ happy agent read <id> --after-seq 0 --through-seq <match-seq> --page-size 100
608
+ ```
609
+
610
+ timeout은 조회·복구·정리를 포함한 한 관측 호출의 예산이다(기본45초, 1~60초).
611
+ 작업은 계속되며 부모가 전체 작업 예산을 별도로 유지한다. 재호출은 원래 afterSeq와 checkpoint를 보존한다.
612
+ 신규 자식 최초 작업의 기준점은 0이다. 기존 세션은 요청 전에 기준점을 정하고 결과가 해당 요청의 답인지 확인한다.
613
+ read의 다음 페이지는 page.nextSeq를 after-seq로, page.throughSeq를 같은 상한으로 쓴다.
614
+ hasMore=false는 그 구간만 소진했음을 뜻한다. 이후 본문 갱신·추가 메시지를 필요에 따라 다시 읽는다.
615
+
616
+ 완료 사건의 failed/cancelled/blocked 상태는 작업 성공이 아니다. responseFinality는 unknown이다.
617
+ 체크포인트는 원문 없는 caller-held DTO이며 인증 토큰이 아니다. 64KiB/미확정256행 상한을 넘으면
618
+ history_incomplete로 끝나고 앞 이력을 버리지 않는다. capability_unavailable은 미지원,
619
+ observation_unavailable은 조회/정리 오류다(대상 조회 실패는 reason=target_lookup_failed, 다른 대상으로 바뀌면 target_changed).
620
+ 관측 중 대상이 삭제되거나 scope를 벗어나면 session_not_found(reason=target_not_found), 남긴 이력에 사건 없이
621
+ 멈춰 있으면 session_not_running(reason=target_inactive)이다. 서버 계약 확인 자체가 실패하면 observation_unavailable(reason=capability_check_failed)다.
622
+ 잘못된 옵션 조합은 invalid_arguments, 이력 조회가 거절되면 access_denied(reason=credential_rejected|history_forbidden)이며
623
+ 같은 호출을 재시도해도 바뀌지 않는다. 첫 페이지 전에 예산이 끝난 wait_timeout도 afterSeq와 받은 checkpoint를 그대로 돌려준다. 모두 ok=false, exit3이며 작업을 자동 stop/prompt/spawn하지 않는다.
624
+ 실제 설치판·broker·10분 이상 작업 수락은 기능 spec의 남은 검증 항목을 따른다.
@@ -71,7 +71,7 @@ function number(value, max = Number.MAX_SAFE_INTEGER) {
71
71
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > max) fail$5("widget", "invalid number");
72
72
  return value;
73
73
  }
74
- function bounded(value) {
74
+ function bounded$1(value) {
75
75
  const encoded = JSON.stringify(value);
76
76
  if (!encoded || new TextEncoder().encode(encoded).length > 32768) fail$5("widget", "payload exceeds 32 KiB");
77
77
  }
@@ -104,7 +104,7 @@ function parseWidgetTree(value) {
104
104
  return { type: "list", children: node.children.map((child) => walk(child, depth + 1)) };
105
105
  };
106
106
  const result = walk(value, 1);
107
- bounded(result);
107
+ bounded$1(result);
108
108
  return result;
109
109
  }
110
110
  function parseWidgetSnapshot(value) {
@@ -132,7 +132,7 @@ function parseWidgetSnapshot(value) {
132
132
  quota: { state: String(quota.state), reason: quota.reason === null ? null : text$3(quota.reason, 256), observedAt: quota.observedAt === null ? null : number(quota.observedAt), windows },
133
133
  goal: goal ? { status: text$3(goal.status, 64), text: text$3(goal.text) } : null
134
134
  };
135
- bounded(result);
135
+ bounded$1(result);
136
136
  return result;
137
137
  }
138
138
  function parseExtensionFileViewers(value, apiVersion) {
@@ -389,6 +389,214 @@ function assertExtensionProjectToolTargets(extensionId2, tools, commands) {
389
389
  if (!command.panelId) fail$5(`${path2}.command`, "expected a command that opens a panel");
390
390
  }
391
391
  }
392
+ const SESSION_HOOK_ACTIVATION_PREFIX = "onSessionOpenBefore:";
393
+ const MAX_HOOKS = 4;
394
+ const MAX_ENV = 32;
395
+ const MAX_SERVERS = 8;
396
+ const MAX_ARGS = 32;
397
+ const MAX_COPY = 32;
398
+ const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
399
+ const SERVER_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
400
+ const HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
401
+ const COMMAND = /^[a-z][a-z0-9._-]{0,63}$/;
402
+ const RESERVED_ENV = /* @__PURE__ */ new Set([
403
+ "PATH",
404
+ "HOME",
405
+ "USER",
406
+ "LOGNAME",
407
+ "SHELL",
408
+ "PWD",
409
+ "TMPDIR",
410
+ "TERM",
411
+ "PORT",
412
+ "SSH_AUTH_SOCK",
413
+ "IFS",
414
+ "PS4",
415
+ "PROMPT_COMMAND",
416
+ "ENV",
417
+ "BASH_ENV",
418
+ "ZDOTDIR",
419
+ "SHELLOPTS",
420
+ "BASHOPTS",
421
+ "COMSPEC",
422
+ "PATHEXT",
423
+ "PYTHONPATH",
424
+ "PYTHONHOME",
425
+ "PYTHONSTARTUP",
426
+ "PERL5LIB",
427
+ "PERL5OPT",
428
+ "PERLLIB",
429
+ "RUBYOPT",
430
+ "RUBYLIB",
431
+ "JAVA_TOOL_OPTIONS",
432
+ "_JAVA_OPTIONS",
433
+ "JDK_JAVA_OPTIONS",
434
+ "GCONV_PATH"
435
+ ]);
436
+ const RESERVED_ENV_PREFIXES = ["HAPPY_", "SAYCODE_", "APLUS_", "LD_", "DYLD_", "GIT_", "ANTHROPIC_", "CLAUDE_", "OPENAI_", "CODEX_", "NODE_", "NPM_CONFIG_", "BASH_FUNC_"];
437
+ const RESERVED_SERVER_PREFIXES = ["happy", "saycode", "aplus"];
438
+ const RESERVED_SERVER_NAMES = /* @__PURE__ */ new Set(["cua-driver"]);
439
+ const UNSUPPORTED_COMMANDS = /^(sh|bash|zsh|fish|dash|ksh|csh|tcsh|cmd|powershell|pwsh|wsl|env|xargs|ssh|tmux|screen|osascript|sudo|su|doas|nohup|awk|gawk|eval|exec|start|cscript|wscript|mshta|rundll32)$/;
440
+ const INTERPRETERS = /^(node|nodejs|deno|bun|python[0-9.]*|py|ruby|perl|php)$/;
441
+ const RUNNERS = /^(npx|pnpx|bunx|uvx|uv|pipx|yarn|pnpm|npm|deno|bun)$/;
442
+ const EVAL_SUBCOMMANDS = /* @__PURE__ */ new Set(["eval", "repl"]);
443
+ const INLINE_CODE_FLAGS = /^(-[eEcpr]|--(eval|print|import|require|loader|experimental-loader|call|shell|command))(=|$)|^-[eEcp]./;
444
+ const RUNNER_SHELL_FLAGS = /^(-c|--call|--shell|--shell-auto-fallback|exec)(=|$)/;
445
+ function programName(command) {
446
+ return command.toLowerCase().replace(/\.(exe|cmd|bat|com|ps1)$/, "");
447
+ }
448
+ function isReservedLaunchEnvName(name) {
449
+ return RESERVED_ENV.has(name) || RESERVED_ENV_PREFIXES.some((prefix) => name.startsWith(prefix));
450
+ }
451
+ function isReservedLaunchMcpServerName(name) {
452
+ return RESERVED_SERVER_NAMES.has(name) || RESERVED_SERVER_PREFIXES.some((prefix) => name.startsWith(prefix));
453
+ }
454
+ function bounded(items, max, path2, min = 0) {
455
+ if (items.length < min || items.length > max) fail$5(path2, `expected ${min} to ${max} entries`);
456
+ return items;
457
+ }
458
+ function envName(value, path2) {
459
+ const name = string(value, path2);
460
+ if (!ENV_NAME.test(name)) fail$5(path2, "expected an upper-case environment variable name");
461
+ if (isReservedLaunchEnvName(name)) fail$5(path2, `reserved name ${name}`);
462
+ return name;
463
+ }
464
+ function parseEnv(value, path2) {
465
+ return bounded(contributionList(value, path2, ["name", "secret"], (entry, entryPath) => {
466
+ if (entry.secret !== void 0 && entry.secret !== true) fail$5(`${entryPath}.secret`, "expected true when present");
467
+ return { name: envName(entry.name, `${entryPath}.name`), ...entry.secret === true ? { secret: true } : {} };
468
+ }), MAX_ENV, path2);
469
+ }
470
+ function parseArgs(command, value, path2) {
471
+ const args = bounded(array(value, path2), MAX_ARGS, path2).map((arg, index) => {
472
+ if (typeof arg !== "string" || arg.length > 512 || arg.includes("\0")) fail$5(`${path2}[${index}]`, "expected a string up to 512 characters");
473
+ return arg;
474
+ });
475
+ const words = [command, ...args];
476
+ const interpreterAt = words.findIndex((word) => INTERPRETERS.test(programName(word)));
477
+ const inline = interpreterAt >= 0 && words.slice(interpreterAt + 1).some((arg) => INLINE_CODE_FLAGS.test(arg) || EVAL_SUBCOMMANDS.has(arg));
478
+ const runnerShell = RUNNERS.test(programName(command)) && args.some((arg) => RUNNER_SHELL_FLAGS.test(arg) || EVAL_SUBCOMMANDS.has(arg));
479
+ if (inline || runnerShell) fail$5(path2, "inline code is not allowed; run a published package or file");
480
+ return args;
481
+ }
482
+ function parseUrl(value, path2) {
483
+ const url = string(value, path2);
484
+ let parsed;
485
+ try {
486
+ parsed = new URL(url);
487
+ } catch {
488
+ fail$5(path2, "expected an https URL");
489
+ }
490
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password || url.length > 2048) fail$5(path2, "expected an https URL without credentials");
491
+ return url;
492
+ }
493
+ function parseServer(entry, path2, env) {
494
+ const name = string(entry.name, `${path2}.name`);
495
+ if (!SERVER_NAME.test(name)) fail$5(`${path2}.name`, "expected a lower-case server name");
496
+ if (isReservedLaunchMcpServerName(name)) fail$5(`${path2}.name`, `reserved name ${name}`);
497
+ const declared = new Set(env.map((item) => item.name));
498
+ if (entry.transport === "stdio") {
499
+ assertOnlyKeys(entry, /* @__PURE__ */ new Set(["name", "transport", "command", "args", "envNames"]), path2);
500
+ const command = string(entry.command, `${path2}.command`);
501
+ if (!COMMAND.test(command) || UNSUPPORTED_COMMANDS.test(programName(command))) fail$5(`${path2}.command`, "unsupported command");
502
+ const args = entry.args === void 0 ? void 0 : parseArgs(command, entry.args, `${path2}.args`);
503
+ const envNames = entry.envNames === void 0 ? void 0 : array(entry.envNames, `${path2}.envNames`).map((item, index) => {
504
+ const itemName = string(item, `${path2}.envNames[${index}]`);
505
+ if (!declared.has(itemName)) fail$5(`${path2}.envNames[${index}]`, "expected an env name declared by the same hook");
506
+ return itemName;
507
+ });
508
+ return { name, transport: "stdio", command, ...args ? { args } : {}, ...envNames ? { envNames } : {} };
509
+ }
510
+ if (entry.transport === "http") {
511
+ assertOnlyKeys(entry, /* @__PURE__ */ new Set(["name", "transport", "url", "headerEnvNames"]), path2);
512
+ const url = parseUrl(entry.url, `${path2}.url`);
513
+ let headerEnvNames;
514
+ if (entry.headerEnvNames !== void 0) {
515
+ const headers = Object.entries(record$1(entry.headerEnvNames, `${path2}.headerEnvNames`));
516
+ bounded(headers, MAX_SERVERS, `${path2}.headerEnvNames`);
517
+ headerEnvNames = {};
518
+ for (const [header, raw] of headers) {
519
+ const target = string(raw, `${path2}.headerEnvNames.${header}`);
520
+ if (!HEADER_NAME.test(header)) fail$5(`${path2}.headerEnvNames`, `invalid header ${header}`);
521
+ if (!env.some((item) => item.name === target && item.secret)) fail$5(`${path2}.headerEnvNames.${header}`, "expected a secret env declared by the same hook");
522
+ headerEnvNames[header] = target;
523
+ }
524
+ }
525
+ return { name, transport: "http", url, ...headerEnvNames ? { headerEnvNames } : {} };
526
+ }
527
+ fail$5(`${path2}.transport`, "expected stdio or http");
528
+ }
529
+ function parseExtensionSessionHooks(value, apiVersion) {
530
+ const path2 = "manifest.contributes.sessionHooks";
531
+ if (apiVersion < 4) fail$5(path2, "requires Extension API version 4");
532
+ const hooks = contributionList(value, path2, ["id", "event", "scope", "dynamic", "env", "mcpServers"], (entry, entryPath) => {
533
+ if (entry.event !== "session.open.before") fail$5(`${entryPath}.event`, "expected session.open.before");
534
+ if (entry.scope !== void 0 && entry.scope !== "all" && entry.scope !== "company") fail$5(`${entryPath}.scope`, "expected all or company");
535
+ if (entry.dynamic !== void 0 && entry.dynamic !== true) fail$5(`${entryPath}.dynamic`, "expected true when present");
536
+ const env = entry.env === void 0 ? [] : parseEnv(entry.env, `${entryPath}.env`);
537
+ const servers = entry.mcpServers === void 0 ? [] : bounded(contributionList(
538
+ entry.mcpServers,
539
+ `${entryPath}.mcpServers`,
540
+ ["name", "transport", "command", "args", "envNames", "url", "headerEnvNames"],
541
+ (server, serverPath) => parseServer(server, serverPath, env)
542
+ ), MAX_SERVERS, `${entryPath}.mcpServers`);
543
+ if (env.length === 0 && servers.length === 0) fail$5(entryPath, "expected env or mcpServers to inject");
544
+ return {
545
+ id: id(entry.id, `${entryPath}.id`),
546
+ event: "session.open.before",
547
+ ...entry.scope === void 0 ? {} : { scope: entry.scope },
548
+ ...entry.dynamic === true ? { dynamic: true } : {},
549
+ ...entry.env === void 0 ? {} : { env },
550
+ ...entry.mcpServers === void 0 ? {} : { mcpServers: servers }
551
+ };
552
+ });
553
+ bounded(hooks, MAX_HOOKS, path2);
554
+ const envNames = /* @__PURE__ */ new Set();
555
+ const serverNames = /* @__PURE__ */ new Set();
556
+ for (const hook of hooks) {
557
+ for (const item of hook.env ?? []) {
558
+ if (envNames.has(item.name)) fail$5(path2, `duplicate env name ${item.name}`);
559
+ envNames.add(item.name);
560
+ }
561
+ for (const server of hook.mcpServers ?? []) {
562
+ if (serverNames.has(server.name)) fail$5(path2, `duplicate MCP server name ${server.name}`);
563
+ serverNames.add(server.name);
564
+ }
565
+ }
566
+ return hooks;
567
+ }
568
+ function parseExtensionWorktreeHooks(value, apiVersion) {
569
+ const path2 = "manifest.contributes.worktreeHooks";
570
+ if (apiVersion < 4) fail$5(path2, "requires Extension API version 4");
571
+ return bounded(contributionList(value, path2, ["id", "event", "copyIgnored"], (entry, entryPath) => {
572
+ if (entry.event !== "worktree.create.after") fail$5(`${entryPath}.event`, "expected worktree.create.after");
573
+ const copyIgnored = bounded(array(entry.copyIgnored, `${entryPath}.copyIgnored`), MAX_COPY, `${entryPath}.copyIgnored`, 1).map((raw, index) => {
574
+ const itemPath = `${entryPath}.copyIgnored[${index}]`;
575
+ const relative = relativePath(raw, itemPath);
576
+ if (relative.length > 512 || relative.split("/").includes(".git")) fail$5(itemPath, "expected a path outside .git");
577
+ return relative;
578
+ });
579
+ return { id: id(entry.id, `${entryPath}.id`), event: "worktree.create.after", copyIgnored };
580
+ }), MAX_HOOKS, path2);
581
+ }
582
+ function assertExtensionLaunchHooks(input) {
583
+ const pairs = [
584
+ ["sessionHooks", "sessions.launchEnvironment", input.sessionHooks],
585
+ ["worktreeHooks", "worktrees.prepare", input.worktreeHooks]
586
+ ];
587
+ for (const [key, permission, hooks] of pairs) {
588
+ if (hooks.length > 0 && !input.permissions.includes(permission)) fail$5(`manifest.contributes.${key}`, `requires ${permission} permission`);
589
+ if (hooks.length === 0 && input.permissions.includes(permission)) fail$5("manifest.permissions", `${permission} requires a ${key} contribution`);
590
+ for (const [index, hook] of hooks.entries()) {
591
+ if (!hook.id.startsWith(`${input.extensionId}.`)) fail$5(`manifest.contributes.${key}[${index}].id`, "expected an id declared by the same extension");
592
+ }
593
+ }
594
+ for (const [index, hook] of input.sessionHooks.entries()) {
595
+ if (hook.dynamic && !input.activationEvents.includes(`${SESSION_HOOK_ACTIVATION_PREFIX}${hook.id}`)) {
596
+ fail$5(`manifest.contributes.sessionHooks[${index}].dynamic`, `requires activation event ${SESSION_HOOK_ACTIVATION_PREFIX}${hook.id}`);
597
+ }
598
+ }
599
+ }
392
600
  const EXTENSION_ARTIFACT_ACTION_OPERATIONS = ["publishPublic"];
393
601
  const EXTENSION_ARTIFACT_SOURCE_TYPES = ["project-file", "personal-chat-file"];
394
602
  const ID_PATTERN$1 = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
@@ -657,13 +865,16 @@ const EXTENSION_PERMISSIONS = [
657
865
  // Core → adapter, not a capability the adapter invokes: Core sends the current input and the
658
866
  // thread's earlier messages (other people's words included) for display-text normalization
659
867
  // (specs/slack-bot-improvements contracts §2). Channel API 2 only.
660
- "channels.normalize"
868
+ "channels.normalize",
869
+ // specs/extension-session-and-worktree-hooks — env·MCP at session open, and copying ignored files into a new worktree.
870
+ "sessions.launchEnvironment",
871
+ "worktrees.prepare"
661
872
  ];
662
873
  const CONVERSATION_MODS_INTERNAL = typeof __SAYCODE_MODS_INTERNAL__ !== "undefined" && __SAYCODE_MODS_INTERNAL__ === true;
663
874
  const SAYCODE_EXTENSION_API_VERSION = CONVERSATION_MODS_INTERNAL ? 5 : 4;
664
875
  const SAYCODE_EXTENSION_MINIMUM_API_VERSION = CONVERSATION_MODS_INTERNAL ? 4 : 2;
665
876
  const MAX_EXTENSION_API_WINDOW_WIDTH = 2;
666
- const SAYCODE_EXTENSION_ENGINE_VERSION = "1.3.0";
877
+ const SAYCODE_EXTENSION_ENGINE_VERSION = "1.4.0";
667
878
  const SAYCODE_CHANNEL_API_VERSION = CHANNEL_API_VERSION;
668
879
  const SAYCODE_CHANNEL_MINIMUM_API_VERSION = CHANNEL_MINIMUM_API_VERSION;
669
880
  class UnsupportedChannelApiVersionError extends Error {
@@ -685,7 +896,7 @@ class UnsupportedExtensionApiVersionError extends Error {
685
896
  const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
686
897
  const ENGINE_RANGE_PATTERN = /^(?:\^|~|>=|<=|>|<)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
687
898
  const LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Z]{2})?$/;
688
- const CONTRIBUTION_KEYS = /* @__PURE__ */ new Set(["chatWidgets", "commands", "settings", "panels", "projectTemplates", "machineActions", "projectTools", "artifactActions", "channels", "fileViewers", "elementPickInspectors"]);
899
+ const CONTRIBUTION_KEYS = /* @__PURE__ */ new Set(["chatWidgets", "commands", "settings", "panels", "projectTemplates", "machineActions", "projectTools", "artifactActions", "channels", "fileViewers", "elementPickInspectors", "sessionHooks", "worktreeHooks"]);
689
900
  const V2_PERMISSIONS = /* @__PURE__ */ new Set([
690
901
  "browserViewer.open",
691
902
  "browserViewer.install",
@@ -895,6 +1106,8 @@ function parseContributions(value, apiVersion, channelApiVersion) {
895
1106
  if (parsed.channels !== void 0) {
896
1107
  result.channels = parseExtensionChannelContributions(parsed.channels);
897
1108
  }
1109
+ if (parsed.sessionHooks !== void 0) result.sessionHooks = parseExtensionSessionHooks(parsed.sessionHooks, apiVersion);
1110
+ if (parsed.worktreeHooks !== void 0) result.worktreeHooks = parseExtensionWorktreeHooks(parsed.worktreeHooks, apiVersion);
898
1111
  const ids = [
899
1112
  ...result.chatWidgets ?? [],
900
1113
  ...result.commands ?? [],
@@ -905,7 +1118,9 @@ function parseContributions(value, apiVersion, channelApiVersion) {
905
1118
  ...result.projectTools ?? [],
906
1119
  ...result.artifactActions ?? [],
907
1120
  ...result.channels ?? [],
908
- ...result.elementPickInspectors ?? []
1121
+ ...result.elementPickInspectors ?? [],
1122
+ ...result.sessionHooks ?? [],
1123
+ ...result.worktreeHooks ?? []
909
1124
  ].map((entry) => entry.id);
910
1125
  if (new Set(ids).size !== ids.length) fail$5("manifest.contributes", "duplicate contribution id");
911
1126
  return result;
@@ -939,6 +1154,7 @@ function parseExtensionManifest(value, options) {
939
1154
  if (typed.startsWith("sessionContext.") && apiVersion !== 5) fail$5("manifest.permissions", "sessionContext requires API 5");
940
1155
  if (typed === "filePreviews.read" && apiVersion < 4) fail$5("manifest.permissions", "filePreviews.read requires Extension API version 4");
941
1156
  if (typed === "browserPicks.inspect" && apiVersion < 4) fail$5("manifest.permissions", "browserPicks.inspect requires Extension API version 4");
1157
+ if ((typed === "sessions.launchEnvironment" || typed === "worktrees.prepare") && apiVersion < 4) fail$5("manifest.permissions", `${typed} requires Extension API version 4`);
942
1158
  if (apiVersion < 2 && V2_PERMISSIONS.has(typed)) {
943
1159
  fail$5(`manifest.permissions[${index}]`, "requires Extension API version 2");
944
1160
  }
@@ -1022,6 +1238,13 @@ function parseExtensionManifest(value, options) {
1022
1238
  assertExtensionFileViewers(parsedId, contributes.fileViewers ?? [], contributes.panels ?? [], permissions);
1023
1239
  assertExtensionElementPickInspectors(parsedId, contributes.elementPickInspectors ?? [], permissions);
1024
1240
  assertExtensionProjectToolTargets(parsedId, contributes.projectTools ?? [], contributes.commands ?? []);
1241
+ assertExtensionLaunchHooks({
1242
+ extensionId: parsedId,
1243
+ permissions,
1244
+ activationEvents,
1245
+ sessionHooks: contributes.sessionHooks ?? [],
1246
+ worktreeHooks: contributes.worktreeHooks ?? []
1247
+ });
1025
1248
  return {
1026
1249
  id: parsedId,
1027
1250
  version,
@@ -1341,6 +1564,40 @@ function sameJson(a, b) {
1341
1564
  }
1342
1565
  const utf8Bytes = (text2) => new TextEncoder().encode(text2).length;
1343
1566
  const hasLoneSurrogate = (text2) => /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(text2);
1567
+ const SESSION_KINDS = /* @__PURE__ */ new Set(["chat", "child", "automation", "channel"]);
1568
+ const SHORT_TEXT = /^[^\u0000-\u001f]{1,200}$/;
1569
+ function shortText(value) {
1570
+ return typeof value === "string" && SHORT_TEXT.test(value);
1571
+ }
1572
+ function onlyKeys(value, keys2) {
1573
+ return Object.keys(value).every((key) => keys2.includes(key));
1574
+ }
1575
+ function plain(value) {
1576
+ return !!value && typeof value === "object" && !Array.isArray(value);
1577
+ }
1578
+ function parseSessionOpenBeforeInput(value) {
1579
+ const invalid = () => {
1580
+ throw new Error("invalid session.openBefore input");
1581
+ };
1582
+ if (!plain(value) || !onlyKeys(value, ["reason", "sessionKind", "agent", "project"])) invalid();
1583
+ const input = value;
1584
+ if (input.reason !== "create" && input.reason !== "resume" || !SESSION_KINDS.has(String(input.sessionKind)) || !shortText(input.agent)) invalid();
1585
+ const project = input.project;
1586
+ if (!plain(project) || !onlyKeys(project, ["id", "companyId", "repository"]) || !shortText(project.id) || project.companyId !== void 0 && !shortText(project.companyId)) invalid();
1587
+ const repository = project.repository;
1588
+ if (repository !== void 0 && (!plain(repository) || !onlyKeys(repository, ["name", "remoteHost"]) || !shortText(repository.name) || repository.remoteHost !== void 0 && !shortText(repository.remoteHost))) invalid();
1589
+ const typedProject = project;
1590
+ return {
1591
+ reason: input.reason,
1592
+ sessionKind: input.sessionKind,
1593
+ agent: input.agent,
1594
+ project: {
1595
+ id: typedProject.id,
1596
+ ...typedProject.companyId ? { companyId: typedProject.companyId } : {},
1597
+ ...typedProject.repository ? { repository: { name: typedProject.repository.name, ...typedProject.repository.remoteHost ? { remoteHost: typedProject.repository.remoteHost } : {} } } : {}
1598
+ }
1599
+ };
1600
+ }
1344
1601
  function finiteNumber(value, path2) {
1345
1602
  if (typeof value !== "number" || !Number.isFinite(value)) fail(`${path2} must be a finite number`);
1346
1603
  return value;
@@ -1474,6 +1731,9 @@ function parseRequest(message) {
1474
1731
  }
1475
1732
  case "channel.normalizeMessages":
1476
1733
  return { kind: "request", id: id2, method, params: parseChannelNormalizeRequest(params) };
1734
+ case "session.openBefore":
1735
+ exactKeys(params, ["hookId", "input"], "request.params");
1736
+ return { kind: "request", id: id2, method, params: { hookId: text(params.hookId, "request.params.hookId"), input: parseSessionOpenBeforeInput(params.input) } };
1477
1737
  case "invokeCommand": {
1478
1738
  exactKeys(params, ["commandId", "args"], "request.params");
1479
1739
  if (!Array.isArray(params.args)) fail("request.params.args must be an array");
@@ -1602,6 +1862,7 @@ class ExtensionHostRuntime {
1602
1862
  activation = null;
1603
1863
  module = null;
1604
1864
  commandHandlers = /* @__PURE__ */ new Map();
1865
+ sessionOpenHandlers = /* @__PURE__ */ new Map();
1605
1866
  ongoingRequestIds = /* @__PURE__ */ new Set();
1606
1867
  respondedIds = /* @__PURE__ */ new Set();
1607
1868
  pendingCapabilityRequests = /* @__PURE__ */ new Map();
@@ -1722,6 +1983,7 @@ class ExtensionHostRuntime {
1722
1983
  this.module = null;
1723
1984
  this.widgets.clear();
1724
1985
  this.commandHandlers.clear();
1986
+ this.sessionOpenHandlers.clear();
1725
1987
  this.channelHandlers.clear();
1726
1988
  this.channelFormatters.clear();
1727
1989
  this.channelNormalizers.clear();
@@ -1766,6 +2028,7 @@ class ExtensionHostRuntime {
1766
2028
  this.active = false;
1767
2029
  this.widgets.clear();
1768
2030
  this.commandHandlers.clear();
2031
+ this.sessionOpenHandlers.clear();
1769
2032
  this.channelHandlers.clear();
1770
2033
  this.channelFormatters.clear();
1771
2034
  this.channelNormalizers.clear();
@@ -1791,6 +2054,13 @@ class ExtensionHostRuntime {
1791
2054
  if (request.method === "channel.formatReply") return this.formatChannelReply(request.params);
1792
2055
  if (request.method === "channel.normalizeMessages") return this.normalizeChannelMessages(request.params);
1793
2056
  if (request.method === "channel.intake") return this.deliverChannelIntake(request.params);
2057
+ if (request.method === "session.openBefore") {
2058
+ const handler2 = this.sessionOpenHandlers.get(request.params.hookId);
2059
+ if (!handler2) throw { code: "SESSION_HOOK_NOT_REGISTERED", message: "session hook handler is not registered" };
2060
+ const validated2 = parseExtensionWireMessage({ kind: "response", id: "result", ok: true, result: await handler2(Object.freeze({ ...request.params.input })) });
2061
+ if (validated2.kind !== "response" || !validated2.ok) throw new Error("extension returned an invalid result");
2062
+ return validated2.result;
2063
+ }
1794
2064
  const handler = this.commandHandlers.get(request.params.commandId);
1795
2065
  if (!handler) {
1796
2066
  throw { code: "COMMAND_NOT_REGISTERED", message: `extension command is not registered: ${request.params.commandId}` };
@@ -1906,6 +2176,7 @@ class ExtensionHostRuntime {
1906
2176
  const registeredFormatters = /* @__PURE__ */ new Map();
1907
2177
  const registeredNormalizers = /* @__PURE__ */ new Map();
1908
2178
  const registeredIntake = /* @__PURE__ */ new Map();
2179
+ const registeredSessionHooks = /* @__PURE__ */ new Map();
1909
2180
  await loaded.activate({
1910
2181
  extensionId: this.options.manifest.id,
1911
2182
  chatWidgets: { register: (id2, render) => {
@@ -1945,6 +2216,14 @@ class ExtensionHostRuntime {
1945
2216
  registered.set(commandId, handler);
1946
2217
  }
1947
2218
  },
2219
+ sessionHooks: {
2220
+ onOpenBefore: (hookId, handler) => {
2221
+ if (!this.activating || !this.options.manifest.contributes.sessionHooks?.some((hook) => hook.id === hookId && hook.dynamic) || typeof handler !== "function" || registeredSessionHooks.has(hookId)) {
2222
+ throw { code: "INVALID_SESSION_HOOK_REGISTRATION", message: "session hook must be a declared dynamic hook and registered once" };
2223
+ }
2224
+ registeredSessionHooks.set(hookId, handler);
2225
+ }
2226
+ },
1948
2227
  invokeCapability: (permission, action, args) => this.invokeCapability(permission, action, args)
1949
2228
  });
1950
2229
  if (this.disposed) {
@@ -1963,6 +2242,8 @@ class ExtensionHostRuntime {
1963
2242
  for (const [id2, handler] of registeredChannels) this.channelHandlers.set(id2, handler);
1964
2243
  this.commandHandlers.clear();
1965
2244
  for (const [commandId, handler] of registered) this.commandHandlers.set(commandId, handler);
2245
+ this.sessionOpenHandlers.clear();
2246
+ for (const [hookId, handler] of registeredSessionHooks) this.sessionOpenHandlers.set(hookId, handler);
1966
2247
  this.active = true;
1967
2248
  } finally {
1968
2249
  this.activating = false;