@notis_ai/cli 0.2.0-beta.153.2 → 0.2.0-beta.155.1

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,47 @@
1
+ // A separate process retains ownership of the build group if the CLI crashes.
2
+ // IPC disconnect is the lifetime signal; unlike polling a PID it cannot mistake
3
+ // a reused PID for the original owner.
4
+ import { spawn } from 'node:child_process';
5
+
6
+ if (!process.send) throw new Error('Build supervisor requires an IPC owner');
7
+ let build;
8
+ let stopping = false;
9
+ function stop(code = 0) {
10
+ if (stopping) return;
11
+ stopping = true;
12
+ if (!build?.pid) process.exit(code);
13
+ if (process.platform === 'win32') {
14
+ const killer = spawn('taskkill', ['/pid', String(build.pid), '/t', '/f']);
15
+ killer.once('error', () => process.exit(1));
16
+ killer.once('exit', () => process.exit(code));
17
+ return;
18
+ }
19
+ const signalGroup = (signal) => {
20
+ try { process.kill(-build.pid, signal); } catch (error) {
21
+ if (error.code !== 'ESRCH') throw error;
22
+ }
23
+ };
24
+ signalGroup('SIGTERM');
25
+ // npm can exit before Vite/esbuild. Keep the supervisor alive until the
26
+ // entire group has received the fallback signal.
27
+ setTimeout(() => {
28
+ signalGroup('SIGKILL');
29
+ process.exit(code);
30
+ }, 1000);
31
+ }
32
+ process.on('disconnect', () => stop());
33
+ process.on('SIGTERM', () => stop());
34
+ process.on('SIGINT', () => stop());
35
+ process.once('message', ({ command, args }) => {
36
+ if (stopping || !process.connected) return;
37
+ build = spawn(command, args, {
38
+ detached: process.platform !== 'win32',
39
+ stdio: 'inherit',
40
+ env: process.env,
41
+ });
42
+ build.once('spawn', () => {
43
+ if (process.connected) process.send({ pid: build.pid }, () => {});
44
+ });
45
+ build.once('error', () => stop(1));
46
+ build.once('exit', (code) => stop(code || 0));
47
+ });
@@ -0,0 +1,41 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // Expose the actual npm group identity so Desktop recovery and diagnostics
6
+ // retain their existing ownership contract. The supervisor owns its lifetime.
7
+ export async function startAppDevBuild(cwd, command = 'npm', args = ['run', 'build', '--', '--watch']) {
8
+ const supervisor = spawn(process.execPath, [fileURLToPath(new URL('./app-dev-build-supervisor.js', import.meta.url))], {
9
+ cwd,
10
+ stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
11
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NOTIS_DEV: '1' },
12
+ });
13
+ const build = new EventEmitter();
14
+ build.pid = null;
15
+ build.exitCode = null;
16
+ build.signalCode = null;
17
+ build.kill = (signal = 'SIGTERM') => supervisor.kill(signal);
18
+ await new Promise((resolve, reject) => {
19
+ supervisor.once('error', reject);
20
+ supervisor.once('exit', (code, signal) => {
21
+ build.exitCode = code;
22
+ build.signalCode = signal;
23
+ reject(new Error(`Build supervisor exited before startup (${code ?? signal})`));
24
+ build.emit('exit', code, signal);
25
+ });
26
+ supervisor.once('message', ({ pid }) => {
27
+ build.pid = pid;
28
+ resolve();
29
+ });
30
+ supervisor.send({ command, args }, (error) => { if (error) reject(error); });
31
+ });
32
+ return build;
33
+ }
34
+
35
+ export async function stopAppDevBuild(build) {
36
+ if (!build || build.exitCode !== null || build.signalCode !== null) return;
37
+ await new Promise((resolve) => {
38
+ build.once('exit', resolve);
39
+ build.kill('SIGTERM');
40
+ });
41
+ }
@@ -44,6 +44,7 @@ import {
44
44
  readAppDevSessions,
45
45
  } from './app-dev-sessions.js';
46
46
  import { captureDesktopWatcherOwnership } from './app-dev-process-identity.js';
47
+ import { startAppDevBuild } from './app-dev-build.js';
47
48
 
48
49
  const CONTENT_TYPES = {
49
50
  '.js': 'application/javascript; charset=utf-8',
@@ -1007,12 +1008,7 @@ export async function startAppDevServer({
1007
1008
  watchManifestInputs(state);
1008
1009
  pollForBundleAndWatch(state);
1009
1010
 
1010
- const buildProcess = spawn('npm', ['run', 'build', '--', '--watch'], {
1011
- cwd: state.projectDir,
1012
- detached: process.platform !== 'win32',
1013
- stdio: 'inherit',
1014
- env: { ...process.env, NOTIS_DEV: '1' },
1015
- });
1011
+ const buildProcess = await startAppDevBuild(state.projectDir);
1016
1012
  state.buildProcess = buildProcess;
1017
1013
  for (let attempt = 0; attempt < 5 && !state.watcherOwnership; attempt += 1) {
1018
1014
  state.watcherOwnership = captureDesktopWatcherOwnership({
@@ -485,10 +485,15 @@ export function updateConfig(updater) {
485
485
  }
486
486
 
487
487
  /**
488
- * Remove only worktree-owned profiles without normalizing the rest of the
489
- * shared file. Archive cleanup can run before a packaged Desktop upgrade has
490
- * migrated its legacy `jwt`; preserving unknown/raw fields here keeps that
491
- * migrate-then-strip handoff intact.
488
+ * Remove every profile owned by one worktree, plus stale localhost stubs that
489
+ * use one of that worktree's generated profile names. Older builds sometimes
490
+ * lost `dev_workspace_root`, so requiring the ownership marker alone leaves a
491
+ * dead local profile behind after archive.
492
+ *
493
+ * Do this without normalizing the rest of the shared file. Archive cleanup can
494
+ * run before a packaged Desktop upgrade has migrated its legacy `jwt`;
495
+ * preserving unknown/raw fields here keeps that migrate-then-strip handoff
496
+ * intact.
492
497
  */
493
498
  export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
494
499
  return withConfigWriteLock((configFile) => {
@@ -502,16 +507,19 @@ export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
502
507
  return [];
503
508
  }
504
509
 
510
+ const generatedNames = new Set(profileNames);
505
511
  const removed = [];
506
- for (const name of profileNames) {
507
- const profile = Object.hasOwn(raw.profiles, name) ? raw.profiles[name] : null;
508
- if (
509
- !profile
510
- || typeof profile !== 'object'
511
- || profile.dev_workspace_root !== workspaceRoot
512
- ) {
512
+ for (const [name, profile] of Object.entries(raw.profiles)) {
513
+ if (!profile || typeof profile !== 'object') {
513
514
  continue;
514
515
  }
516
+
517
+ const ownedByWorkspace = profile.dev_workspace_root === workspaceRoot;
518
+ const staleGeneratedLocalProfile = generatedNames.has(name)
519
+ && !profile.dev_workspace_root
520
+ && isLocalApiBase(profile.api_base);
521
+ if (!ownedByWorkspace && !staleGeneratedLocalProfile) continue;
522
+
515
523
  delete raw.profiles[name];
516
524
  removed.push(name);
517
525
  if (raw.current_profile === name) raw.current_profile = DEFAULT_PROFILE;
@@ -72,13 +72,15 @@ export async function updateAgentTargets(
72
72
  serverUrl: string,
73
73
  jwt: string,
74
74
  skillId: string,
75
- targets: AgentTargets,
76
- ): Promise<{ success: boolean; agent_targets: AgentTargets }> {
77
- return requestJson<{ success: boolean; agent_targets: AgentTargets }>(`${serverUrl}/portal_skills/agent-targets`, jwt, {
75
+ targets: Partial<AgentTargets>,
76
+ expectedUpdatedAt?: string,
77
+ ): Promise<{ success: boolean; agent_targets: AgentTargets; updated_at?: string }> {
78
+ return requestJson<{ success: boolean; agent_targets: AgentTargets; updated_at?: string }>(`${serverUrl}/portal_skills/agent-targets`, jwt, {
78
79
  method: 'PATCH',
79
80
  body: {
80
81
  skill_id: skillId,
81
82
  agent_targets: targets,
83
+ ...(expectedUpdatedAt ? { expected_updated_at: expectedUpdatedAt } : {}),
82
84
  },
83
85
  });
84
86
  }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AgentTargets,
2
3
  LocalSkill,
3
4
  NotisSyncState,
4
5
  SkillSyncFailure,
@@ -49,6 +50,7 @@ export interface RunSkillSyncResult {
49
50
  /** Skills the server rejected (e.g. an invalid SKILL.md description). The rest of
50
51
  * the batch still syncs; these are surfaced so the user knows what to fix. */
51
52
  failedPushes: SkillSyncFailure[];
53
+ failedLinks?: SkillSyncFailure[];
52
54
  }
53
55
 
54
56
  export interface RunSkillSyncOptions {
@@ -81,6 +83,7 @@ export interface MaterializeCloudSkillsResult {
81
83
  deleted: number;
82
84
  removed: number;
83
85
  lastSyncedAt: string | null;
86
+ failedLinks?: SkillSyncFailure[];
84
87
  }
85
88
 
86
89
  export interface MaterializeCloudSkillsOptions {
@@ -174,6 +177,7 @@ function buildSyncState(
174
177
  pullResponse: SyncPullResponse,
175
178
  localSkills: LocalSkill[],
176
179
  lastSyncedAt: string | null,
180
+ verifiedAgentLinks: Record<string, Partial<AgentTargets>> = {},
177
181
  ): NotisSyncState {
178
182
  const localSkillMap = toSkillMap(localSkills);
179
183
  const skills = Object.fromEntries(
@@ -185,6 +189,8 @@ function buildSyncState(
185
189
  cloudId: skill.id,
186
190
  folderHash: localSkill?.folderHash || skill.skill_folder_hash || "",
187
191
  agentTargets: normalizeAgentTargets(skill.agent_targets),
192
+ verifiedAgentLinks: skill.status === "active" ? verifiedAgentLinks[skill.name] ?? {} : {},
193
+ cloudUpdatedAt: skill.updated_at,
188
194
  syncedAt: lastSyncedAt || new Date().toISOString(),
189
195
  },
190
196
  ];
@@ -268,6 +274,7 @@ async function writePulledSkillsToScopedMirror(
268
274
  RunSkillSyncDependencies,
269
275
  "downloadSkillBundle" | "writeCloudSkillToDisk"
270
276
  >,
277
+ failures: SkillSyncFailure[] = [],
271
278
  ): Promise<number> {
272
279
  const localSkillMap = toSkillMap(localSkills);
273
280
  const warnSkillSync = (message: string, error: unknown): void => {
@@ -289,6 +296,8 @@ async function writePulledSkillsToScopedMirror(
289
296
  })
290
297
  ) {
291
298
  downloaded += 1;
299
+ } else {
300
+ failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
292
301
  }
293
302
  }
294
303
 
@@ -346,27 +355,44 @@ export async function materializeCloudSkillsForLocalShell(
346
355
  const previousState = await deps.readSyncState(syncPaths);
347
356
 
348
357
  const localSkills = await deps.scanLocalSkills(syncPaths);
358
+ const failedDownloads: SkillSyncFailure[] = [];
349
359
  const downloaded = await writePulledSkillsToScopedMirror(
350
360
  pullResponse,
351
361
  localSkills,
352
362
  previousState,
353
363
  syncPaths,
354
364
  deps,
365
+ failedDownloads,
355
366
  );
356
367
  const finalLocalSkills = await deps.scanLocalSkills(syncPaths);
357
368
  const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
358
369
 
359
370
  const relinkSkillNames = new Set(options.relinkSkillNames || []);
371
+ const failures = [...failedDownloads];
372
+ const verifiedLinks: Record<string, Partial<AgentTargets>> = {};
373
+ for (const skill of pullResponse.skills) {
374
+ const previous = previousState.skills[skill.name];
375
+ if (skill.updated_at && previous?.cloudId === skill.id && previous.cloudUpdatedAt === skill.updated_at) {
376
+ verifiedLinks[skill.name] = { ...previous.verifiedAgentLinks };
377
+ }
378
+ }
360
379
  if (relinkSkillNames.size > 0) {
361
- await deps.syncSymlinks(
380
+ const relinked = await deps.syncSymlinks(
362
381
  pullResponse.skills.filter((skill) => relinkSkillNames.has(skill.name)),
363
382
  syncPaths.skillsDir,
364
383
  { removeUndesired: false },
365
384
  );
385
+ failures.push(...(relinked.failures ?? []).filter(
386
+ (failure) => !failedDownloads.some((download) => download.name === failure.name),
387
+ ));
388
+ for (const name of relinkSkillNames) {
389
+ verifiedLinks[name] = relinked.verifiedAgentLinks?.[name] ?? {};
390
+ }
366
391
  }
392
+ for (const failure of failedDownloads) delete verifiedLinks[failure.name];
367
393
 
368
394
  await deps.writeSyncState(
369
- buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt),
395
+ buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
370
396
  syncPaths,
371
397
  );
372
398
 
@@ -376,6 +402,7 @@ export async function materializeCloudSkillsForLocalShell(
376
402
  deleted: 0,
377
403
  removed: 0,
378
404
  lastSyncedAt,
405
+ ...(failures.length ? { failedLinks: failures } : {}),
379
406
  };
380
407
  }
381
408
 
@@ -397,6 +424,7 @@ async function deactivateDeletedAgentSkills(
397
424
  RunSkillSyncDependencies,
398
425
  "detectDeletedAgentSymlinks" | "updateAgentTargets" | "pullSkills"
399
426
  >,
427
+ failures: SkillSyncFailure[],
400
428
  ): Promise<number> {
401
429
  // First sync (incl. legacy migration) has no reliable "we created this link" signal, so we
402
430
  // cannot tell a user deletion apart from a never-created link — skip detection entirely.
@@ -413,34 +441,6 @@ async function deactivateDeletedAgentSkills(
413
441
  return 0;
414
442
  }
415
443
 
416
- // The agent-targets endpoint replaces the whole agent_targets column, so re-read the freshest
417
- // values right before writing and merge onto them, only flipping the deleted agent. This avoids
418
- // clobbering a concurrent portal/other-desktop change to a DIFFERENT agent with the stale
419
- // snapshot from the top-of-sync pull. (A narrow read-modify-write window remains; the endpoint
420
- // has no partial update.)
421
- const latestSkillsById = new Map(pullResponse.skills.map((skill) => [skill.id, skill]));
422
- let fresh: SyncPullResponse | null = null;
423
- try {
424
- fresh = await deps.pullSkills(serverUrl, jwt);
425
- } catch (error) {
426
- console.warn(
427
- "[skill-sync] Could not re-pull latest agent targets before deactivation; " +
428
- "using the top-of-sync snapshot.",
429
- error,
430
- );
431
- }
432
- if (fresh) {
433
- // A transport failure may fall back to the already-authorized snapshot, but
434
- // an explicit legacy denial must abort before any server or local mutation.
435
- assertSkillsPullAuthorized(fresh);
436
- for (const skill of fresh.skills) {
437
- latestSkillsById.set(skill.id, skill);
438
- }
439
- }
440
-
441
- // Group deletions by skill so multiple deleted agents for the SAME skill are flipped in one
442
- // PATCH. Otherwise each PATCH, rebuilt from the same snapshot, would overwrite the previous one
443
- // (deleting both claude_code and cursor for one skill would otherwise leave only the last off).
444
444
  const agentsBySkill = new Map<string, { skillName: string; agents: Set<DeletedAgentSymlink["agent"]> }>();
445
445
  for (const deletion of deletions) {
446
446
  const entry = agentsBySkill.get(deletion.skillId) ?? {
@@ -451,33 +451,39 @@ async function deactivateDeletedAgentSkills(
451
451
  agentsBySkill.set(deletion.skillId, entry);
452
452
  }
453
453
 
454
- const inMemoryById = new Map(pullResponse.skills.map((skill) => [skill.id, skill]));
454
+ const fresh = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
455
+ assertSkillsPullAuthorized(fresh);
456
+ Object.assign(pullResponse, fresh);
457
+ let needsRefresh = false;
455
458
  let deactivated = 0;
456
459
  for (const [skillId, { skillName, agents }] of agentsBySkill) {
457
- const latest = latestSkillsById.get(skillId);
458
- if (!latest) {
459
- continue;
460
- }
461
- const nextTargets = { ...normalizeAgentTargets(latest.agent_targets) };
462
- for (const agent of agents) {
463
- nextTargets[agent] = false;
464
- }
460
+ const skill = pullResponse.skills.find((item) => item.id === skillId);
461
+ const previous = previousState.skills[skillName];
462
+ if (!skill?.updated_at || previous?.cloudUpdatedAt !== skill.updated_at) continue;
463
+ const patch = Object.fromEntries([...agents].map((agent) => [agent, false]));
465
464
  try {
466
- await deps.updateAgentTargets(serverUrl, jwt, skillId, nextTargets);
467
- // Mutate the in-memory pull response so the symlink passes below see the link as undesired.
468
- const inMemory = inMemoryById.get(skillId);
469
- if (inMemory) {
470
- inMemory.agent_targets = nextTargets;
465
+ const saved = await deps.updateAgentTargets(serverUrl, jwt, skillId, patch, skill.updated_at);
466
+ if (saved.success !== true || !saved.updated_at?.trim()
467
+ || saved.updated_at === skill.updated_at
468
+ || !['notis', 'claude_code', 'cursor', 'codex'].every((agent) =>
469
+ typeof saved.agent_targets?.[agent as keyof AgentTargets] === 'boolean')
470
+ || ![...agents].every((agent) => saved.agent_targets[agent] === false)) {
471
+ throw new Error('Assignment update did not return a verified saved revision');
471
472
  }
473
+ skill.agent_targets = saved.agent_targets;
474
+ skill.updated_at = saved.updated_at;
472
475
  deactivated += agents.size;
473
476
  } catch (error) {
474
- console.warn(
475
- `[skill-sync] Failed to deactivate "${skillName}" for ${[...agents].join(", ")} ` +
476
- `after local symlink deletion:`,
477
- error,
478
- );
477
+ needsRefresh = true;
478
+ failures.push({ name: skillName, error: 'Could not save the local agent removal; refreshed saved assignments' });
479
+ console.warn(`[skill-sync] Assignment changed or could not be saved for "${skillName}"; refreshing before reconciliation.`, error);
479
480
  }
480
481
  }
482
+ if (needsRefresh) {
483
+ const refreshed = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
484
+ assertSkillsPullAuthorized(refreshed);
485
+ Object.assign(pullResponse, refreshed);
486
+ }
481
487
  return deactivated;
482
488
  }
483
489
 
@@ -535,6 +541,13 @@ export async function runSkillSync(
535
541
  .map((skill) => skill.name),
536
542
  );
537
543
  const protectedSkillNames = new Set([...cloudCuratedSkillNames, ...BASE_SKILL_NAMES]);
544
+ const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
545
+ const assignmentFailures: SkillSyncFailure[] = [];
546
+ const deactivated = syncSettings.agent_targets_conditional_updates === true
547
+ ? await deactivateDeletedAgentSkills(
548
+ serverUrl, jwt, pullResponse, scopedState, scopedState, syncPaths.skillsDir, deps, assignmentFailures,
549
+ )
550
+ : 0;
538
551
  const authUserId = decodeJwtSubject(jwt);
539
552
  let previousAuthState: NotisSyncState | null = null;
540
553
  if (authUserId && authUserId !== syncUserId) {
@@ -550,7 +563,6 @@ export async function runSkillSync(
550
563
  });
551
564
  const localSkills = (await deps.scanLocalSkills(syncPaths))
552
565
  .filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
553
- const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
554
566
  const previousState = withoutBaseSkillState(applyLegacyFirstRunState(
555
567
  localSkills,
556
568
  scopedState,
@@ -560,18 +572,7 @@ export async function runSkillSync(
560
572
  : previousAuthState)
561
573
  : null,
562
574
  ));
563
- // Honor locally-deleted per-agent symlinks as deactivations before reconciling, otherwise the
564
- // first syncSymlinks pass would recreate the link the user just deleted.
565
- const deactivated = await deactivateDeletedAgentSkills(
566
- serverUrl,
567
- jwt,
568
- pullResponse,
569
- previousState,
570
- scopedState,
571
- syncPaths.skillsDir,
572
- deps,
573
- );
574
- await deps.syncSymlinks(
575
+ const gatheredSymlinkResult = await deps.syncSymlinks(
575
576
  buildLocalSymlinkCandidates(pullResponse, localSkills, previousState),
576
577
  syncPaths.skillsDir,
577
578
  );
@@ -607,12 +608,14 @@ export async function runSkillSync(
607
608
  }
608
609
  }
609
610
 
611
+ const failedDownloads: SkillSyncFailure[] = [];
610
612
  const downloaded = await writePulledSkillsToScopedMirror(
611
613
  pullResponse,
612
614
  localSkills,
613
615
  previousState,
614
616
  syncPaths,
615
617
  deps,
618
+ failedDownloads,
616
619
  );
617
620
 
618
621
  const finalLocalSkills = (await deps.scanLocalSkills(syncPaths))
@@ -621,10 +624,12 @@ export async function runSkillSync(
621
624
  buildLocalSymlinkCandidates(pullResponse, finalLocalSkills, previousState),
622
625
  syncPaths.skillsDir,
623
626
  );
627
+ const verifiedLinks = { ...(symlinkResult.verifiedAgentLinks ?? {}) };
628
+ for (const failure of failedDownloads) delete verifiedLinks[failure.name];
624
629
  const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
625
630
 
626
631
  await deps.writeSyncState(
627
- buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt),
632
+ buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
628
633
  syncPaths,
629
634
  );
630
635
 
@@ -635,9 +640,12 @@ export async function runSkillSync(
635
640
  downloaded,
636
641
  deleted,
637
642
  deactivated,
638
- linked: symlinkResult.linked,
639
- removed: foreignLinksRemoved + symlinkResult.removed,
643
+ linked: gatheredSymlinkResult.linked + symlinkResult.linked,
644
+ removed: foreignLinksRemoved + gatheredSymlinkResult.removed + symlinkResult.removed,
640
645
  skipped: symlinkResult.skipped,
646
+ failedLinks: [...assignmentFailures, ...failedDownloads, ...(symlinkResult.failures ?? []).filter(
647
+ (failure) => !failedDownloads.some((download) => download.name === failure.name),
648
+ )],
641
649
  lastSyncedAt,
642
650
  failedPushes,
643
651
  };
@@ -8,7 +8,7 @@ import {
8
8
  NOTIS_SKILL_SYNC_ROOT,
9
9
  safeName,
10
10
  } from './local-scanner';
11
- import { normalizeAgentTargets, type CloudSkill, type NotisSyncState } from './types';
11
+ import { normalizeAgentTargets, type AgentTargets, type SkillSyncFailure, type CloudSkill, type NotisSyncState } from './types';
12
12
 
13
13
  const HOME_DIR = os.homedir();
14
14
 
@@ -22,6 +22,22 @@ type ExternalAgent = keyof typeof EXTERNAL_AGENT_SKILL_DIRS;
22
22
 
23
23
  const EXTERNAL_AGENTS = Object.keys(EXTERNAL_AGENT_SKILL_DIRS) as ExternalAgent[];
24
24
 
25
+ const AGENT_FAILURE_LABELS: Record<string, string> = {
26
+ claude_code: 'Claude Code',
27
+ cursor: 'Cursor',
28
+ codex: 'Codex',
29
+ legacy: 'legacy ~/.agents/skills',
30
+ };
31
+
32
+ /** Failures are shown next to skill names in the Portal and CLI; never leak raw agent ids. */
33
+ function agentFailureLabel(agent: string): string {
34
+ return AGENT_FAILURE_LABELS[agent] ?? agent;
35
+ }
36
+
37
+ function agentFolderFailureName(agent: string): string {
38
+ return `${agentFailureLabel(agent)} skills folder`;
39
+ }
40
+
25
41
  export interface DeletedAgentSymlink {
26
42
  skillId: string;
27
43
  skillName: string;
@@ -46,6 +62,8 @@ export interface SymlinkSyncResult {
46
62
  linked: number;
47
63
  removed: number;
48
64
  skipped: number;
65
+ verifiedAgentLinks?: Record<string, Partial<AgentTargets>>;
66
+ failures?: SkillSyncFailure[];
49
67
  }
50
68
 
51
69
  /** Remove only links into another account's managed mirror. This is safe even
@@ -128,8 +146,9 @@ async function isManagedSymlink(linkPath: string, managedRoots: string[]): Promi
128
146
  return managedRoots.some((root) => (
129
147
  resolvedTarget === root || resolvedTarget.startsWith(`${root}${path.sep}`)
130
148
  ));
131
- } catch {
132
- return false;
149
+ } catch (error) {
150
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return false;
151
+ throw error;
133
152
  }
134
153
  }
135
154
 
@@ -146,8 +165,8 @@ async function ensureCorrectSymlink(linkPath: string, targetPath: string): Promi
146
165
  } else {
147
166
  return 'blocked';
148
167
  }
149
- } catch {
150
- // Missing path is expected.
168
+ } catch (error) {
169
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
151
170
  }
152
171
 
153
172
  const relativePath = path.relative(path.dirname(linkPath), targetPath);
@@ -166,6 +185,8 @@ async function removeUndesiredManagedSymlinks(
166
185
  agentDir: string,
167
186
  desiredSkills: Set<string>,
168
187
  managedRoots: string[],
188
+ failures: SkillSyncFailure[],
189
+ agent: string,
169
190
  ): Promise<number> {
170
191
  let removed = 0;
171
192
  try {
@@ -173,13 +194,19 @@ async function removeUndesiredManagedSymlinks(
173
194
  const existingEntries = await fs.readdir(agentDir, { withFileTypes: true });
174
195
  for (const entry of existingEntries) {
175
196
  const entryPath = path.join(agentDir, entry.name);
176
- if (!desiredSkills.has(entry.name) && await isManagedSymlink(entryPath, managedRoots)) {
177
- await fs.unlink(entryPath);
178
- removed += 1;
197
+ try {
198
+ if (!desiredSkills.has(entry.name) && await isManagedSymlink(entryPath, managedRoots)) {
199
+ await fs.unlink(entryPath);
200
+ removed += 1;
201
+ }
202
+ } catch (error) {
203
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
204
+ failures.push({ name: entry.name, error: `${agentFailureLabel(agent)}: could not remove skill link (${(error as Error).message})` });
205
+ }
179
206
  }
180
207
  }
181
- } catch {
182
- // Missing or unreadable agent directories should not block skill sync.
208
+ } catch (error) {
209
+ failures.push({ name: agentFolderFailureName(agent), error: `Could not read agent skills directory (${(error as Error).message})` });
183
210
  }
184
211
  return removed;
185
212
  }
@@ -255,7 +282,9 @@ export async function detectDeletedAgentSymlinks(
255
282
  continue;
256
283
  }
257
284
  const previous = previousState.skills[skill.name];
258
- if (!previous) {
285
+ if (!previous || previous.cloudId !== skill.id
286
+ || previous.verifiedAgentLinks?.[agent] !== true
287
+ || !skill.updated_at || previous.cloudUpdatedAt !== skill.updated_at) {
259
288
  continue;
260
289
  }
261
290
 
@@ -293,10 +322,12 @@ export async function syncSymlinks(
293
322
  ): Promise<SymlinkSyncResult> {
294
323
  await fs.mkdir(skillsDir, { recursive: true });
295
324
 
296
- const result: SymlinkSyncResult = {
325
+ const result: Required<SymlinkSyncResult> = {
297
326
  linked: 0,
298
327
  removed: 0,
299
328
  skipped: 0,
329
+ verifiedAgentLinks: {},
330
+ failures: [],
300
331
  };
301
332
  const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
302
333
  const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
@@ -336,23 +367,36 @@ export async function syncSymlinks(
336
367
  continue;
337
368
  }
338
369
 
339
- await fs.mkdir(agentDir, { recursive: true });
370
+ try {
371
+ await fs.mkdir(agentDir, { recursive: true });
372
+ } catch (error) {
373
+ result.failures.push({ name: agentFolderFailureName(agent), error: `Could not create agent skills directory (${(error as Error).message})` });
374
+ continue;
375
+ }
340
376
 
341
377
  const desiredSkills = desiredByAgent[agent];
342
378
  if (options.removeUndesired !== false) {
343
- result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots);
379
+ result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots, result.failures, agent);
344
380
  }
345
381
 
346
382
  for (const skillName of desiredSkills) {
347
383
  const targetPath = path.join(skillsDir, skillName);
348
384
  const linkPath = path.join(agentDir, safeName(skillName, agentDir));
349
385
  try {
350
- await fs.access(targetPath);
386
+ if (!(await fs.stat(path.join(targetPath, "SKILL.md"))).isFile()) throw new Error("Missing SKILL.md");
351
387
  } catch {
352
388
  result.skipped += 1;
389
+ result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: SKILL.md is missing or unreadable` });
390
+ continue;
391
+ }
392
+ let syncOutcome;
393
+ try {
394
+ syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
395
+ } catch (error) {
396
+ result.skipped += 1;
397
+ result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: could not create skill link (${(error as Error).message})` });
353
398
  continue;
354
399
  }
355
- const syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
356
400
  if (syncOutcome === 'linked') {
357
401
  result.linked += 1;
358
402
  } else if (syncOutcome === 'blocked') {
@@ -360,9 +404,13 @@ export async function syncSymlinks(
360
404
  `[skill-sync] Could not link "${skillName}" for ${agent}: non-symlink entry blocks ${linkPath}`,
361
405
  );
362
406
  result.skipped += 1;
407
+ result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: an existing file or folder blocks the skill link` });
363
408
  } else {
364
409
  result.skipped += 1;
365
410
  }
411
+ if (syncOutcome !== "blocked") {
412
+ result.verifiedAgentLinks[skillName] = { ...result.verifiedAgentLinks[skillName], [agent]: true };
413
+ }
366
414
  }
367
415
  }
368
416
 
@@ -376,6 +424,8 @@ export async function syncSymlinks(
376
424
  legacyGlobalSkillsDir,
377
425
  new Set(),
378
426
  managedRoots,
427
+ result.failures,
428
+ 'legacy',
379
429
  );
380
430
  }
381
431