@lmzhen/dsh-evolution-core 0.27.3 → 0.29.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.
@@ -85,7 +85,7 @@ export function orderVersions(versions) {
85
85
  /** The actions that record a SUPPORT FILE's bytes (they share the skill's version index). */
86
86
  const SUPPORT_ACTIONS = ['write_file', 'remove_file'];
87
87
  /** The actions that record the skill's own SKILL.md body. */
88
- const CONTENT_ACTIONS = ['create', 'update', 'patch', 'consolidate', 'restructure', 'restore'];
88
+ const CONTENT_ACTIONS = ['create', 'update', 'patch', 'consolidate', 'restructure', 'restore', 'merge'];
89
89
  /**
90
90
  * Which artifact one recorded version holds, from the action that produced it.
91
91
  * @param action - the entry's action label.
@@ -610,6 +610,7 @@ export function versionActionKind(action) {
610
610
  case 'archive': return 'archive';
611
611
  case 'consolidate': return 'consolidate';
612
612
  case 'restructure': return 'restructure';
613
+ case 'merge': return 'merge';
613
614
  case 'write_file': return 'support-write';
614
615
  case 'remove_file': return 'support-remove';
615
616
  default: return 'other';
@@ -37,7 +37,7 @@ import { transactIo, type EvolutionIoLike } from './io.ts';
37
37
  import { type Probe } from './probe.ts';
38
38
  import type { SkillLimits } from './limits.ts';
39
39
  import { type MutationRecord } from './mutations.ts';
40
- import { type SkillVersion } from './skill-history.ts';
40
+ import { type UnitHead, type SkillVersion } from './skill-history.ts';
41
41
  import { type SkillHealthAssessment, type SkillHealthThresholds } from './skill-health.ts';
42
42
  import type { EvolutionSkillMutatedEvent } from './events.ts';
43
43
  export interface SkillSummary {
@@ -174,6 +174,39 @@ export interface ArchiveOptions {
174
174
  reason?: string;
175
175
  /** Permit archiving a bundled skill (curator prune-builtins only; hub-installed and pinned stay protected). */
176
176
  allowBundled?: boolean;
177
+ /** Unit shared with the rest of a multi-skill transaction (merge). */
178
+ unitId?: string;
179
+ /** Cross-skill landmark for the unit; a merge source names the target. */
180
+ unitHead?: UnitHead;
181
+ }
182
+ /** One multi-skill merge commit: target body + support files, with the source names it absorbs. */
183
+ export interface CommitMergePlan {
184
+ readonly target: string;
185
+ /** Whether the target already exists. The caller planned this; commit re-checks it. */
186
+ readonly targetExists: boolean;
187
+ readonly sources: readonly string[];
188
+ readonly body: string;
189
+ readonly supportFiles: readonly {
190
+ readonly path: string;
191
+ readonly content: string;
192
+ }[];
193
+ /** Plan-time target body; null for a create. */
194
+ readonly expectedTargetBody: string | null;
195
+ readonly expectedSourceBodies: readonly {
196
+ readonly name: string;
197
+ readonly body: string;
198
+ }[];
199
+ readonly origin: WriteOrigin;
200
+ readonly summary: string;
201
+ }
202
+ export interface CommitMergeResult {
203
+ readonly ok: boolean;
204
+ readonly message: string;
205
+ readonly unitId?: string;
206
+ readonly targetPath?: string;
207
+ readonly archived?: readonly string[];
208
+ /** One per skill written: target + each source. */
209
+ readonly mutations?: number;
177
210
  }
178
211
  export declare function skillsRoot(env?: NodeJS.ProcessEnv): string;
179
212
  /** 0.3.18 (S4.1, E-30): the ONE root resolution for every member that reads
@@ -270,6 +303,19 @@ export declare function markerEntryName(marker: 'bundled' | 'hub-installed' | 'p
270
303
  * rule, or it creates an orphan the later patch/write/remove paths refuse.
271
304
  * Single source shared with the plan validator. */
272
305
  export declare function validateRestructureTarget(filePath: string): string | null;
306
+ export declare function validateSupportPath(filePath: string): string | null;
307
+ /**
308
+ * Support-directory references in a markdown body (009 kernel): `references/…`,
309
+ * `templates/…`, `scripts/…`, `assets/…` relative links — any extension and
310
+ * nested paths (v7 audit P3-1: `.md`-only matching missed `scripts/run.sh` and
311
+ * `references/sub/x.md`, both of which dangle just like a `.md` link once the
312
+ * source package is archived). Pure — who checks them and what the verdict is
313
+ * belongs to the caller's context (a moved/appended body whose references
314
+ * travel with an archived source is a dangling link; a restructure pointer is
315
+ * a fresh link to a file written in the same plan). `..` traversal is a
316
+ * different defect class (path validation owns it) and is not a support link.
317
+ */
318
+ export declare function supportRefs(content: string): string[];
273
319
  export declare class SkillLibrary {
274
320
  readonly root: string;
275
321
  readonly limits: SkillLimits;
@@ -487,7 +533,10 @@ export declare class SkillLibrary {
487
533
  */
488
534
  setPinned(name: string, pinned: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
489
535
  private setPinnedCore;
490
- create(name: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
536
+ create(name: string, content: string, origin?: WriteOrigin, options?: {
537
+ unitId?: string;
538
+ unitHead?: UnitHead;
539
+ }): Promise<SkillActionResult>;
491
540
  private createCore;
492
541
  update(rawName: string, content: string, origin?: WriteOrigin, anchor?: WriteAnchor): Promise<SkillActionResult>;
493
542
  private updateCore;
@@ -519,6 +568,16 @@ export declare class SkillLibrary {
519
568
  * instead of throwing `name.includes is not a function`. */
520
569
  private safeSnapshotEntryName;
521
570
  archive(rawName: string, options?: ArchiveOptions, origin?: WriteOrigin): Promise<SkillActionResult>;
571
+ /**
572
+ * Commit one merge plan as a single multi-skill transaction.
573
+ *
574
+ * Existing target: archive every source first, then write the target through the
575
+ * tree-change kernel. Create target: create the target with its final body first
576
+ * (so absorbedInto can see it), then archive sources and write support files.
577
+ * Every failure rolls back what already landed; a rollback failure names the
578
+ * unrestored skills instead of claiming success.
579
+ */
580
+ commitMerge(plan: CommitMergePlan): Promise<CommitMergeResult>;
522
581
  /**
523
582
  * V2 (design §16.7): what an apply-mode consolidation would do for ONE source,
524
583
  * computed BEFORE any side effect. `blocked` means behaviour is exactly the
@@ -608,6 +667,8 @@ export declare class SkillLibrary {
608
667
  * @returns the expired entry names ([] when the backend has no mtime probe,
609
668
  * because an unknown age must never read as expired).
610
669
  */
670
+ /** Names currently in .archive, for target occupancy; unknown listing is null. */
671
+ archivedNames(): Promise<string[] | null>;
611
672
  expiredArchives(): Promise<string[] | null>;
612
673
  /** V4 (design §16.6): support-file char policy, resolved at the call site. */
613
674
  supportFileCharPolicy(): 'report' | 'enforce';
@@ -352,7 +352,7 @@ export function validateRestructureTarget(filePath) {
352
352
  return `toFile "${filePath}" uses a Windows reserved device name.`;
353
353
  return null;
354
354
  }
355
- function validateSupportPath(filePath) {
355
+ export function validateSupportPath(filePath) {
356
356
  const normalized = filePath.replace(/\\/g, '/');
357
357
  if (normalized.includes('..'))
358
358
  return '不允许路径穿越。';
@@ -393,7 +393,7 @@ function validateSupportPath(filePath) {
393
393
  * a fresh link to a file written in the same plan). `..` traversal is a
394
394
  * different defect class (path validation owns it) and is not a support link.
395
395
  */
396
- function supportRefs(content) {
396
+ export function supportRefs(content) {
397
397
  const refs = [];
398
398
  for (const match of content.matchAll(/\b(?:references|templates|scripts|assets)\/[A-Za-z0-9._/-]+/g)) {
399
399
  const ref = match[0];
@@ -1338,15 +1338,15 @@ export class SkillLibrary {
1338
1338
  path: dir,
1339
1339
  };
1340
1340
  }
1341
- async create(name, content, origin = this.authority.origin) {
1341
+ async create(name, content, origin = this.authority.origin, options = {}) {
1342
1342
  const normalized = name.trim();
1343
1343
  // P2-3 (v11): the exists-probe → write window runs under the in-process
1344
1344
  // serialize queue (F-208 discipline, like update/patch) — two concurrent
1345
1345
  // create calls for one name used to both pass exists() and double-write
1346
1346
  // (double audit, double mutation event).
1347
- return await this.serial(() => this.createCore(normalized, content, origin));
1347
+ return await this.serial(() => this.createCore(normalized, content, origin, options));
1348
1348
  }
1349
- async createCore(normalized, content, origin) {
1349
+ async createCore(normalized, content, origin, options = {}) {
1350
1350
  // C-12 (v10 audit): the inline badName() copy is gone (see setPinned).
1351
1351
  const bad = this.badName(normalized);
1352
1352
  if (bad)
@@ -1511,7 +1511,7 @@ export class SkillLibrary {
1511
1511
  return { ok: false, message: `创建过程中技能「${normalized}」被并发归档;残留标记已移除:等搬运完成后重试一次。` };
1512
1512
  }
1513
1513
  }
1514
- await this.audit(normalized, 'create', null, onDisk, 'created');
1514
+ await this.audit(normalized, 'create', null, onDisk, 'created', undefined, options.unitId, options.unitHead);
1515
1515
  this.notifyMutation({ action: 'create', name: normalized, skillDir: dir });
1516
1516
  // Both warnings ride the normal success path (audit/event included) — a
1517
1517
  // durability warning must never skip the accounting.
@@ -1919,10 +1919,222 @@ export class SkillLibrary {
1919
1919
  catch {
1920
1920
  // Same posture as .archive-reason: metadata only, never abort the archive.
1921
1921
  }
1922
- await this.audit(name, 'archive', md, null, reason, undefined, undefined, undefined, origin);
1922
+ await this.audit(name, 'archive', md, null, reason, undefined, options.unitId, options.unitHead, origin);
1923
1923
  this.notifyMutation({ action: 'archive', name, archivedPath: dest });
1924
1924
  return { ok: true, message: `技能「${name}」已归档到 .archive。`, path: dest };
1925
1925
  }
1926
+ /**
1927
+ * Commit one merge plan as a single multi-skill transaction.
1928
+ *
1929
+ * Existing target: archive every source first, then write the target through the
1930
+ * tree-change kernel. Create target: create the target with its final body first
1931
+ * (so absorbedInto can see it), then archive sources and write support files.
1932
+ * Every failure rolls back what already landed; a rollback failure names the
1933
+ * unrestored skills instead of claiming success.
1934
+ */
1935
+ async commitMerge(plan) {
1936
+ const target = plan.target.trim();
1937
+ const sources = [...new Set(plan.sources.map(name => name.trim()))].filter(name => name !== target);
1938
+ if (sources.length === 0)
1939
+ return { ok: false, message: '合并至少需要一个互不相同的来源技能。' };
1940
+ for (const name of [target, ...sources]) {
1941
+ const bad = this.badName(name);
1942
+ if (bad)
1943
+ return { ok: false, message: bad };
1944
+ }
1945
+ return await this.serial(async () => {
1946
+ const dir = this.dirOf(target);
1947
+ const targetPath = join(dir, 'SKILL.md');
1948
+ const currentTarget = await this.io.readText(targetPath);
1949
+ if (plan.targetExists !== (currentTarget !== null)) {
1950
+ return { ok: false, message: plan.targetExists ? `目标技能「${target}」不存在。` : `目标技能「${target}」已存在。` };
1951
+ }
1952
+ if (currentTarget !== plan.expectedTargetBody) {
1953
+ return { ok: false, message: `目标技能「${target}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
1954
+ }
1955
+ const targetProtection = await this.writeProtection(target, plan.origin);
1956
+ if (targetProtection)
1957
+ return { ok: false, message: `技能「${target}」受保护(${targetProtection})。` };
1958
+ const expectedSources = new Map(plan.expectedSourceBodies.map(entry => [entry.name.trim(), entry.body]));
1959
+ const sourceBodies = new Map();
1960
+ for (const source of sources) {
1961
+ const protection = await this.deleteProtection(source);
1962
+ if (protection)
1963
+ return { ok: false, message: `技能「${source}」受保护(${protection})。` };
1964
+ const body = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
1965
+ if (body === null)
1966
+ return { ok: false, message: `来源技能「${source}」不存在。` };
1967
+ const expected = expectedSources.get(source);
1968
+ if (expected === undefined)
1969
+ return { ok: false, message: `来源技能「${source}」缺少计划字节;拒绝提交。` };
1970
+ if (body !== expected)
1971
+ return { ok: false, message: `来源技能「${source}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
1972
+ sourceBodies.set(source, body);
1973
+ }
1974
+ const validation = validateFrontmatter(plan.body, target, this.limits);
1975
+ if (validation)
1976
+ return { ok: false, message: validation };
1977
+ const supportPaths = new Set();
1978
+ const supportPrevious = new Map();
1979
+ for (const file of plan.supportFiles) {
1980
+ const badPath = validateSupportPath(file.path);
1981
+ if (badPath)
1982
+ return { ok: false, message: badPath };
1983
+ if (supportPaths.has(file.path))
1984
+ return { ok: false, message: `支撑文件重复:${file.path}` };
1985
+ supportPaths.add(file.path);
1986
+ if (Buffer.byteLength(file.content, 'utf8') > this.limits.maxSkillFileBytes)
1987
+ return { ok: false, message: `支撑文件超过 ${this.limits.maxSkillFileBytes} 字节:${file.path}` };
1988
+ const threat = this.contentThreatBlock(file.content);
1989
+ if (threat)
1990
+ return { ok: false, message: threat };
1991
+ const previous = await this.io.readText(join(dir, ...file.path.replace(/\\/g, '/').split('/').filter(Boolean)));
1992
+ supportPrevious.set(file.path, previous);
1993
+ }
1994
+ for (const ref of supportRefs(plan.body)) {
1995
+ if (supportPaths.has(ref))
1996
+ continue;
1997
+ const existing = plan.targetExists
1998
+ ? await this.io.exists(join(dir, ...ref.replace(/\\/g, '/').split('/').filter(Boolean)))
1999
+ : false;
2000
+ if (!existing)
2001
+ return { ok: false, message: `正文引用了未提供的支撑文件「${ref}」;拒绝提交。` };
2002
+ }
2003
+ const unitId = 'u-' + Date.now().toString(36) + '-' + (nextUnitSeq += 1).toString(36);
2004
+ const supportMoves = plan.supportFiles.map((file) => {
2005
+ const previous = supportPrevious.get(file.path) ?? null;
2006
+ return {
2007
+ path: file.path,
2008
+ kind: previous === null ? 'created' : 'moved',
2009
+ to: file.path,
2010
+ ...(previous === null ? {} : { beforeRef: contentHash(previous) }),
2011
+ afterRef: contentHash(file.content),
2012
+ };
2013
+ });
2014
+ const unitHead = {
2015
+ moves: supportMoves,
2016
+ sources: [{ skill: target }, ...sources.map(source => ({ skill: source }))],
2017
+ };
2018
+ const archived = [];
2019
+ const rollbackSources = async () => {
2020
+ const stuck = [];
2021
+ for (const source of [...archived].reverse()) {
2022
+ try {
2023
+ const restored = await this.restoreFromArchive(source, plan.origin);
2024
+ if (!restored.ok)
2025
+ stuck.push(source);
2026
+ }
2027
+ catch {
2028
+ stuck.push(source);
2029
+ }
2030
+ }
2031
+ return stuck;
2032
+ };
2033
+ const rollbackMessage = (reason, stuck) => ({
2034
+ ok: false,
2035
+ message: stuck.length === 0
2036
+ ? `合并失败,已回滚:${reason}`
2037
+ : `合并失败(${reason});已回滚,但以下未回滚:${stuck.join(', ')}:它们仍在 .archive 里,请用 /evolution skill restore 恢复。`,
2038
+ });
2039
+ if (plan.targetExists) {
2040
+ for (const source of sources) {
2041
+ const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
2042
+ if (fresh !== sourceBodies.get(source)) {
2043
+ const stuck = await rollbackSources();
2044
+ return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
2045
+ }
2046
+ const result = await this.archive(source, {
2047
+ absorbedInto: target,
2048
+ unitId,
2049
+ unitHead: { skill: target },
2050
+ reason: `Merged into ${target}`,
2051
+ }, plan.origin);
2052
+ if (!result.ok) {
2053
+ const stuck = await rollbackSources();
2054
+ return rollbackMessage(result.message, stuck);
2055
+ }
2056
+ archived.push(source);
2057
+ }
2058
+ const writes = [
2059
+ { target: targetPath, content: plan.body.trimEnd() + '\n', expected: plan.expectedTargetBody },
2060
+ ...plan.supportFiles.map(file => ({
2061
+ target: join(dir, ...file.path.replace(/\\/g, '/').split('/').filter(Boolean)),
2062
+ content: file.content,
2063
+ expected: supportPrevious.get(file.path) ?? null,
2064
+ })),
2065
+ ];
2066
+ const result = await this.applyTreeChange({
2067
+ name: target,
2068
+ origin: plan.origin,
2069
+ protection: 'write',
2070
+ writes,
2071
+ auditAction: 'merge',
2072
+ auditSummary: plan.summary,
2073
+ eventAction: 'merge',
2074
+ unitId,
2075
+ unitHead,
2076
+ });
2077
+ if (!result.ok) {
2078
+ const stuck = await rollbackSources();
2079
+ return rollbackMessage(result.message, stuck);
2080
+ }
2081
+ return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
2082
+ }
2083
+ const created = await this.createCore(target, plan.body, plan.origin, { unitId, unitHead });
2084
+ if (!created.ok)
2085
+ return { ok: false, message: created.message };
2086
+ for (const source of sources) {
2087
+ const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
2088
+ if (fresh !== sourceBodies.get(source)) {
2089
+ const stuck = await rollbackSources();
2090
+ const targetRollback = await this.archive(target, { reason: 'merge rolled back' }, plan.origin);
2091
+ if (!targetRollback.ok)
2092
+ stuck.push(target);
2093
+ return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
2094
+ }
2095
+ const result = await this.archive(source, {
2096
+ absorbedInto: target,
2097
+ unitId,
2098
+ unitHead: { skill: target },
2099
+ reason: `Merged into ${target}`,
2100
+ }, plan.origin);
2101
+ if (!result.ok) {
2102
+ const stuck = await rollbackSources();
2103
+ const targetRollback = await this.archive(target, { reason: 'merge rolled back' }, plan.origin);
2104
+ if (!targetRollback.ok)
2105
+ stuck.push(target);
2106
+ return rollbackMessage(result.message, stuck);
2107
+ }
2108
+ archived.push(source);
2109
+ }
2110
+ if (plan.supportFiles.length > 0) {
2111
+ const writes = plan.supportFiles.map(file => ({
2112
+ target: join(dir, ...file.path.replace(/\\/g, '/').split('/').filter(Boolean)),
2113
+ content: file.content,
2114
+ expected: supportPrevious.get(file.path) ?? null,
2115
+ }));
2116
+ const result = await this.applyTreeChange({
2117
+ name: target,
2118
+ origin: plan.origin,
2119
+ protection: 'write',
2120
+ writes,
2121
+ auditAction: 'merge',
2122
+ auditSummary: plan.summary,
2123
+ eventAction: 'merge',
2124
+ unitId,
2125
+ unitHead,
2126
+ });
2127
+ if (!result.ok) {
2128
+ const stuck = await rollbackSources();
2129
+ const targetRollback = await this.archive(target, { reason: 'merge rolled back' }, plan.origin);
2130
+ if (!targetRollback.ok)
2131
+ stuck.push(target);
2132
+ return rollbackMessage(result.message, stuck);
2133
+ }
2134
+ }
2135
+ return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
2136
+ });
2137
+ }
1926
2138
  /**
1927
2139
  * V2 (design §16.7): what an apply-mode consolidation would do for ONE source,
1928
2140
  * computed BEFORE any side effect. `blocked` means behaviour is exactly the
@@ -2517,25 +2729,27 @@ export class SkillLibrary {
2517
2729
  const isBody = land.target.split(/[\\/]/).pop() === 'SKILL.md';
2518
2730
  const rel = isBody ? undefined : land.target.slice(dir.length + 1).split('\\').join('/');
2519
2731
  // G3b(A22):代表片=第一条 landing;kind 判 null(previous 的取值域只有 string/null)。
2520
- const unitHead = land === landing[0] ? {
2521
- moves: landing
2522
- .filter(l => l.target.split(/[\\/]/).pop() !== 'SKILL.md')
2523
- .map((l) => {
2524
- const p = l.target.slice(dir.length + 1).split(String.fromCharCode(92)).join(String.fromCharCode(47));
2525
- return {
2526
- path: p,
2527
- kind: l.previous === null ? 'created' : 'moved',
2528
- to: p,
2529
- ...l.previous === null ? {} : { beforeRef: contentHash(l.previous) },
2530
- afterRef: contentHash(l.content),
2531
- };
2732
+ const unitHead = land === landing[0]
2733
+ ? (plan.unitHead ?? {
2734
+ moves: landing
2735
+ .filter(l => l.target.split(/[\\/]/).pop() !== 'SKILL.md')
2736
+ .map((l) => {
2737
+ const p = l.target.slice(dir.length + 1).split(String.fromCharCode(92)).join(String.fromCharCode(47));
2738
+ return {
2739
+ path: p,
2740
+ kind: l.previous === null ? 'created' : 'moved',
2741
+ to: p,
2742
+ ...l.previous === null ? {} : { beforeRef: contentHash(l.previous) },
2743
+ afterRef: contentHash(l.content),
2744
+ };
2745
+ })
2746
+ // F1 (independent review, 2026-10-14): when one plan writes the SAME path twice, keep the
2747
+ // LAST write -- first-wins left `afterRef` describing the earlier bytes (stale). `beforeRef`
2748
+ // and `kind` are unaffected: both come from the same per-path pre-read.
2749
+ .filter((m, idx, all) => all.map(x => x.path).lastIndexOf(m.path) === idx),
2750
+ sources: [{ skill: name }],
2532
2751
  })
2533
- // F1 (independent review, 2026-10-14): when one plan writes the SAME path twice, keep the
2534
- // LAST write -- first-wins left `afterRef` describing the earlier bytes (stale). `beforeRef`
2535
- // and `kind` are unaffected: both come from the same per-path pre-read.
2536
- .filter((m, idx, all) => all.map(x => x.path).lastIndexOf(m.path) === idx),
2537
- sources: [{ skill: name }],
2538
- } : undefined;
2752
+ : undefined;
2539
2753
  // A22: previous is string | null (a read failure rejects the whole plan), so the old
2540
2754
  // `=== undefined ? md : previous` was an unreachable branch -- the type is the truth.
2541
2755
  const auditedBefore = land.previous;
@@ -2880,6 +3094,13 @@ export class SkillLibrary {
2880
3094
  * @returns the expired entry names ([] when the backend has no mtime probe,
2881
3095
  * because an unknown age must never read as expired).
2882
3096
  */
3097
+ /** Names currently in .archive, for target occupancy; unknown listing is null. */
3098
+ async archivedNames() {
3099
+ const listed = await probeList(this.io, join(this.root, '.archive'));
3100
+ if (isUnknown(listed))
3101
+ return null;
3102
+ return isPresent(listed) ? listed.value.slice() : [];
3103
+ }
2883
3104
  async expiredArchives() {
2884
3105
  const archiveRoot = join(this.root, '.archive');
2885
3106
  // Three-state read (N14), the same helper every other listing uses: a
@@ -6,8 +6,70 @@ function serviceOf(ctx, name) {
6
6
  function messageOf(error) {
7
7
  return error instanceof Error ? error.message : String(error);
8
8
  }
9
+ function textOfValue(value) {
10
+ if (typeof value === 'string')
11
+ return value;
12
+ if (!Array.isArray(value))
13
+ return '';
14
+ return value
15
+ .filter(block => typeof block === 'object' && block !== null && block.type === 'text')
16
+ .map((block) => {
17
+ const text = block.text;
18
+ return typeof text === 'string' ? text : '';
19
+ })
20
+ .join('');
21
+ }
22
+ function abortLink(signal, timeoutMs) {
23
+ const controller = new AbortController();
24
+ const timer = setTimeout(() => { controller.abort(new Error('subagent turn timeout')); }, timeoutMs);
25
+ const onAbort = () => { controller.abort(signal?.reason); };
26
+ signal?.addEventListener('abort', onAbort, { once: true });
27
+ return {
28
+ controller,
29
+ cleanup: () => {
30
+ clearTimeout(timer);
31
+ signal?.removeEventListener('abort', onAbort);
32
+ },
33
+ };
34
+ }
35
+ function waitForSubagentEnd(ctx, childId, signal, timeoutMs) {
36
+ return new Promise((resolve, reject) => {
37
+ let settled = false;
38
+ const finish = (fn) => {
39
+ if (settled)
40
+ return;
41
+ settled = true;
42
+ cleanup();
43
+ fn();
44
+ };
45
+ const eventCtx = ctx;
46
+ const off = typeof eventCtx.on === 'function'
47
+ ? eventCtx.on('subagent/end', (info) => {
48
+ if (info.id !== childId)
49
+ return;
50
+ finish(() => { resolve(info); });
51
+ })
52
+ : () => { };
53
+ const timer = setTimeout(() => {
54
+ finish(() => { reject(new Error('timed out waiting for subagent/end: ' + childId)); });
55
+ }, timeoutMs);
56
+ const onAbort = () => {
57
+ finish(() => { reject(signal.reason instanceof Error ? signal.reason : new Error('conversation turn aborted')); });
58
+ };
59
+ signal.addEventListener('abort', onAbort, { once: true });
60
+ function cleanup() {
61
+ clearTimeout(timer);
62
+ signal.removeEventListener('abort', onAbort);
63
+ off();
64
+ }
65
+ if (signal.aborted)
66
+ onAbort();
67
+ });
68
+ }
9
69
  export function createSubagentChannel(ctx) {
10
70
  let lastKind = 'reviewer';
71
+ const active = new Map();
72
+ const disposed = new Set();
11
73
  const channel = {
12
74
  get state() {
13
75
  return probeChannelState(ctx, lastKind);
@@ -125,6 +187,146 @@ export function createSubagentChannel(ctx) {
125
187
  }
126
188
  }
127
189
  },
190
+ async open(spec) {
191
+ lastKind = spec.kind;
192
+ const probe = probeChannelState(ctx, spec.kind);
193
+ if (probe.kind === 'unavailable')
194
+ return probe;
195
+ if (spec.allow.length === 0)
196
+ return { kind: 'unavailable', code: 'E-324', reason: 'allow set is empty' };
197
+ const subagents = serviceOf(ctx, 'subagents');
198
+ const presets = serviceOf(ctx, 'agentPresets');
199
+ if (subagents === undefined || presets === undefined) {
200
+ return { kind: 'unavailable', code: 'E-323', reason: 'subagents/agentPresets service unavailable' };
201
+ }
202
+ const parentPresetId = presets.composedPreset(spec.parent.ctx);
203
+ if (parentPresetId === undefined) {
204
+ return { kind: 'unavailable', code: 'E-323', reason: 'parent preset could not be resolved' };
205
+ }
206
+ const ensured = await ensureRestrictedPreset(ctx, { kind: spec.kind, parentPresetId, allow: spec.allow });
207
+ if (ensured.kind === 'failed')
208
+ return { kind: 'unavailable', code: 'E-324', reason: ensured.reason };
209
+ const link = abortLink(spec.signal, spec.timeoutMs);
210
+ let activation;
211
+ try {
212
+ activation = await subagents.startActivation({
213
+ provider: 'spawn',
214
+ label: spec.label,
215
+ delivery: 'caller',
216
+ request: {
217
+ parent: spec.parent,
218
+ prompt: [{ type: 'text', text: spec.promptText }],
219
+ agentPreset: ensured.presetId,
220
+ ...(spec.cwd !== undefined ? { cwd: spec.cwd } : {}),
221
+ ...(spec.maxDepth !== undefined ? { maxDepth: spec.maxDepth } : {}),
222
+ ...(spec.agentOptions !== undefined ? { agentOptions: spec.agentOptions } : {}),
223
+ },
224
+ signal: link.controller.signal,
225
+ });
226
+ const result = await activation.result;
227
+ if (result.stopReason === 'aborted') {
228
+ try {
229
+ await activation.dispose();
230
+ }
231
+ catch { /* teardown must not replace the outcome */ }
232
+ return { kind: 'aborted' };
233
+ }
234
+ if (result.stopReason !== 'completed') {
235
+ try {
236
+ await activation.dispose();
237
+ }
238
+ catch { /* teardown must not replace the outcome */ }
239
+ return { kind: 'unavailable', code: 'E-323', reason: 'round 1 did not complete: ' + result.stopReason };
240
+ }
241
+ const turn = {
242
+ childId: activation.childId,
243
+ stopReason: result.stopReason,
244
+ text: textOfValue(result.output),
245
+ ...(result.structured !== undefined ? { structured: result.structured } : {}),
246
+ ...(result.diagnostic !== undefined ? { diagnostic: result.diagnostic } : {}),
247
+ };
248
+ active.set(activation.childId, { childId: activation.childId, parent: spec.parent, activation, disposed: false });
249
+ disposed.delete(activation.childId);
250
+ return { kind: 'ok', childId: activation.childId, turn };
251
+ }
252
+ catch (error) {
253
+ if (activation !== undefined) {
254
+ try {
255
+ await activation.dispose();
256
+ }
257
+ catch { /* teardown must not replace the outcome */ }
258
+ }
259
+ if (link.controller.signal.aborted)
260
+ return { kind: 'aborted' };
261
+ return { kind: 'unavailable', code: 'E-323', reason: messageOf(error) };
262
+ }
263
+ finally {
264
+ link.cleanup();
265
+ }
266
+ },
267
+ async continueTurn(childId, promptText, options) {
268
+ if (disposed.has(childId))
269
+ return { kind: 'unavailable', code: 'E-324', reason: 'conversation is disposed' };
270
+ const current = active.get(childId);
271
+ if (current === undefined || current.disposed) {
272
+ return { kind: 'unavailable', code: 'E-323', reason: 'no open conversation for ' + childId };
273
+ }
274
+ const subagents = serviceOf(ctx, 'subagents');
275
+ if (subagents?.sendMessage === undefined) {
276
+ return { kind: 'unavailable', code: 'E-323', reason: 'subagents.sendMessage unavailable' };
277
+ }
278
+ const link = abortLink(options?.signal, options?.timeoutMs ?? 120_000);
279
+ let pending;
280
+ try {
281
+ pending = waitForSubagentEnd(ctx, childId, link.controller.signal, options?.timeoutMs ?? 120_000);
282
+ await subagents.sendMessage(current.parent, childId, [{ type: 'text', text: promptText }], { signal: link.controller.signal });
283
+ }
284
+ catch (error) {
285
+ link.cleanup();
286
+ if (options?.signal?.aborted === true)
287
+ return { kind: 'aborted' };
288
+ return { kind: 'unavailable', code: 'E-323', reason: messageOf(error) };
289
+ }
290
+ try {
291
+ const info = await pending;
292
+ if (info.stopReason === 'aborted')
293
+ return { kind: 'aborted' };
294
+ const turn = {
295
+ childId,
296
+ stopReason: info.stopReason ?? 'completed',
297
+ text: textOfValue(info.lastAssistantMessage),
298
+ ...(info.diagnostic !== undefined ? { diagnostic: info.diagnostic } : {}),
299
+ };
300
+ return { kind: 'ok', childId, turn };
301
+ }
302
+ catch (error) {
303
+ if (options?.signal?.aborted === true)
304
+ return { kind: 'aborted' };
305
+ return { kind: 'unavailable', code: 'E-323', reason: messageOf(error) };
306
+ }
307
+ finally {
308
+ link.cleanup();
309
+ }
310
+ },
311
+ async dispose(childId) {
312
+ const current = active.get(childId);
313
+ if (current === undefined || current.disposed) {
314
+ disposed.add(childId);
315
+ return;
316
+ }
317
+ current.disposed = true;
318
+ active.delete(childId);
319
+ disposed.add(childId);
320
+ try {
321
+ await current.activation.dispose();
322
+ }
323
+ catch (error) {
324
+ try {
325
+ ctx.logger.warn('subagent conversation: dispose failed: ' + messageOf(error));
326
+ }
327
+ catch { /* logging must not mask teardown */ }
328
+ }
329
+ },
128
330
  };
129
331
  return channel;
130
332
  }