@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 +28 -0
- package/channel-extension-host.mjs +288 -7
- package/index.mjs +134884 -130288
- package/package.json +1 -1
- package/windows-host/saycode-windows-host.exe +0 -0
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.
|
|
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;
|